Repository: paularmstrong/build-tracker
Pull request #47 (2019-04-03 16:38:10)

[app] use redux to manage state
#  Problem

State management has gotten difficult to reason about when it's all done at the top level. Makes passing props through difficult to follow, etc.

# Solution

Redux ¯\_(ツ)_/¯

 This uses `redux-react-hook`

# TODO

- [ ] 🤓 Add & update tests (always try to _increase_ test coverage)
- [ ] 🔬 Ensure CI is passing (`yarn lint:ci`, `yarn test`, `yarn tsc:ci`)
- [ ] 📖 Update relevant documentation



Patch:
diff --git a/config/jest.js b/config/jest.js
index 03b68b5..efda41c 100644
--- a/config/jest.js
+++ b/config/jest.js
@@ -3,6 +3,7 @@ module.exports = {
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
   modulePathIgnorePatterns: ['dist', 'lib'],
   resetMocks: true,
+  restoreMocks: true,
   testPathIgnorePatterns: ['node_modules'],
   testURL: 'https://build-tracker.local',
   timers: 'fake',
diff --git a/src/app/package.json b/src/app/package.json
index c77f7b1..7c61e76 100644
--- a/src/app/package.json
+++ b/src/app/package.json
@@ -28,6 +28,8 @@
     "react": "^16.8.2",
     "react-dom": "^16.8.2",
     "react-native-web": "^0.11.1",
+    "redux": "^4.0.1",
+    "redux-react-hook": "^3.2.1",
     "tosource": "^1.0.0",
     "uuid": "^3.3.2"
   },
@@ -51,6 +53,7 @@
     "react-native-testing-library": "^1.5.0",
     "react-test-renderer": "^16.8.3",
     "react-testing-library": "^6.0.0",
+    "redux-mock-store": "^1.5.3",
     "supertest": "^4.0.0",
     "ts-loader": "^5.3.3",
     "webpack": "^4.29.5",
diff --git a/src/app/src/client/index.tsx b/src/app/src/client/index.tsx
index ffbef68..c92ab48 100644
--- a/src/app/src/client/index.tsx
+++ b/src/app/src/client/index.tsx
@@ -3,9 +3,22 @@
  */
 import { AppRegistry } from 'react-native';
 import Main from '../screens/Main';
+import makeStore from '../store';
+import React from 'react';
 import ReactDOM from 'react-dom';
+import { StoreContext } from 'redux-react-hook';
 
-AppRegistry.registerComponent('App', () => Main);
 // @ts-ignore
-const { element } = AppRegistry.getApplication('App', { initialProps: window.__PROPS__ || {} });
+const initialProps = window.__PROPS__ || {};
+const store = makeStore(initialProps);
+
+const App = (): React.ReactElement => (
+  <StoreContext.Provider value={store}>
+    <Main />
+  </StoreContext.Provider>
+);
+
+AppRegistry.registerComponent('App', () => App);
+// @ts-ignore
+const { element } = AppRegistry.getApplication('App', {});
 ReactDOM.hydrate(element, document.getElementById('root'));
diff --git a/src/app/src/components/BuildInfo.tsx b/src/app/src/components/BuildInfo.tsx
index aa54d9c..2a33973 100644
--- a/src/app/src/components/BuildInfo.tsx
+++ b/src/app/src/components/BuildInfo.tsx
@@ -2,16 +2,17 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import * as Theme from '../theme';
-import Build from '@build-tracker/build';
 import Button from './Button';
 import CloseIcon from '../icons/Close';
 import React from 'react';
+import { setFocusedRevision } from '../store/actions';
+import { State } from '../store/types';
 import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
 import { Table, Tbody, Td, Th, Tr } from './Table';
+import { useDispatch, useMappedState } from 'redux-react-hook';
 
 interface Props {
-  build: Build;
-  onClose?: (revision: string) => void;
+  focusedRevision: string;
   style?: StyleProp<ViewStyle>;
 }
 
@@ -20,12 +21,22 @@ const titleCase = (value: string): string => {
 };
 
 const BuildInfo = (props: Props): React.ReactElement => {
-  const { build, onClose, style } = props;
+  const { focusedRevision, style } = props;
+
+  const mapState = React.useCallback(
+    (state: State) => ({
+      build: state.comparator.builds.find(build => build.getMetaValue('revision') === focusedRevision)
+    }),
+    [focusedRevision]
+  );
+  const { build } = useMappedState(mapState);
   const revision = build.getMetaValue('revision');
 
+  const dispatch = useDispatch();
+
   const handleClose = React.useCallback(() => {
-    onClose(revision);
-  }, [onClose, revision]);
+    dispatch(setFocusedRevision(undefined));
+  }, [dispatch]);
 
   return (
     <View style={[styles.root, style]}>
diff --git a/src/app/src/components/ColorScalePicker/ColorScale.tsx b/src/app/src/components/ColorScalePicker/ColorScale.tsx
index 9664284..1f2b6ed 100644
--- a/src/app/src/components/ColorScalePicker/ColorScale.tsx
+++ b/src/app/src/components/ColorScalePicker/ColorScale.tsx
@@ -2,32 +2,33 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import * as Theme from '../../theme';
+import ColorScales from '../../modules/ColorScale';
 import RadioSelect from '../RadioSelect';
 import React from 'react';
-import { ScaleSequential } from 'd3-scale';
+import { setColorScale } from '../../store/actions';
+import { useDispatch } from 'redux-react-hook';
 import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native';
 
 interface Props {
   isSelected?: boolean;
-  name: string;
-  onSelect: (scale: ScaleSequential<string>) => void;
-  scale: ScaleSequential<string>;
+  name: keyof typeof ColorScales;
   style?: StyleProp<ViewStyle>;
 }
 
 export const ColorScale = (props: Props): React.ReactElement => {
-  const { isSelected, name, onSelect, scale, style } = props;
+  const { isSelected, name, style } = props;
 
+  const dispatch = useDispatch();
   const handleSelect = React.useCallback(
-    (checked: boolean): void => {
+    (checked: boolean) => {
       if (checked) {
-        onSelect(scale);
+        dispatch(setColorScale(name));
       }
     },
-    [onSelect, scale]
+    [dispatch, name]
   );
 
-  const nativeID = `radio${name.replace(/^\w/g, '')}`;
+  const nativeID = `radio${`${name}`.replace(/^\w/g, '')}`;
 
   return (
     // @ts-ignore
diff --git a/src/app/src/components/ColorScalePicker/index.tsx b/src/app/src/components/ColorScalePicker/index.tsx
index cde1f21..f4aa7a4 100644
--- a/src/app/src/components/ColorScalePicker/index.tsx
+++ b/src/app/src/components/ColorScalePicker/index.tsx
@@ -6,28 +6,20 @@ import ColorScale from './ColorScale';
 import ColorScales from '../../modules/ColorScale';
 import React from 'react';
 import { ScaleSequential } from 'd3-scale';
+import { State } from '../../store/types';
+import { useMappedState } from 'redux-react-hook';
 import { StyleSheet, View } from 'react-native';
 
-interface Props {
-  activeColorScale: ScaleSequential<string>;
-  onSelect: (scale: ScaleSequential<string>) => void;
-}
+const mapState = (state: State): { activeColorScale: ScaleSequential<string> } => ({
+  activeColorScale: ColorScales[state.colorScale]
+});
 
-export const ColorScalePicker = (props: Props): React.ReactElement => {
-  const { activeColorScale, onSelect } = props;
+export const ColorScalePicker = (): React.ReactElement => {
+  const { activeColorScale } = useMappedState(mapState);
   return (
     <View style={styles.root}>
       {Object.entries(ColorScales).map(([name, scale]) => {
-        return (
-          <ColorScale
-            isSelected={activeColorScale === scale}
-            key={name}
-            name={name}
-            onSelect={onSelect}
-            scale={scale}
-            style={styles.scale}
-          />
-        );
+        return <ColorScale isSelected={activeColorScale === scale} key={name} name={name} style={styles.scale} />;
       })}
     </View>
   );
diff --git a/src/app/src/components/ComparisonTable/ComparisonTable.tsx b/src/app/src/components/ComparisonTable/ComparisonTable.tsx
index c3a1ff6..5402cfa 100644
--- a/src/app/src/components/ComparisonTable/ComparisonTable.tsx
+++ b/src/app/src/components/ComparisonTable/ComparisonTable.tsx
@@ -30,6 +30,7 @@ const emptyArray = [];
 const ComparisonTable = (props: Props): React.ReactElement => {
   const {
     activeArtifacts,
+    colorScale,
     comparator,
     disabledArtifactsVisible,
     hoveredArtifacts,
@@ -41,7 +42,7 @@ const ComparisonTable = (props: Props): React.ReactElement => {
     onRemoveRevision,
     sizeKey
   } = props;
-  const colorScale = props.colorScale.domain([0, comparator.artifactNames.length]);
+
   const matrix = comparator.toJSON();
 
   const handleMouseOut = React.useCallback(() => {
diff --git a/src/app/src/components/Graph/Area.tsx b/src/app/src/components/Graph/Area.tsx
index 51c0485..ac99aca 100644
--- a/src/app/src/components/Graph/Area.tsx
+++ b/src/app/src/components/Graph/Area.tsx
@@ -3,7 +3,6 @@
  */
 import 'd3-transition';
 import Build from '@build-tracker/build';
-import Comparator from '@build-tracker/comparator';
 import { hsl } from 'd3-color';
 import React from 'react';
 import { select } from 'd3-selection';
@@ -12,8 +11,8 @@ import { ScaleLinear, ScalePoint, ScaleSequential } from 'd3-scale';
 
 interface Props {
   activeArtifactNames: Array<string>;
+  artifactNames: Array<string>;
   colorScale: ScaleSequential<string>;
-  comparator: Comparator;
   data: Array<Series<Build, string>>;
   hoveredArtifacts: Array<string>;
   xScale: ScalePoint<string>;
@@ -21,20 +20,20 @@ interface Props {
 }
 
 const Area = (props: Props): React.ReactElement => {
-  const { activeArtifactNames, comparator, data, hoveredArtifacts, xScale, yScale } = props;
-  const colorScale = props.colorScale.domain([0, comparator.artifactNames.length]);
+  const { activeArtifactNames, artifactNames, colorScale, data, hoveredArtifacts, xScale, yScale } = props;
+
   const gRef = React.useRef(null);
 
   const graphColorScale = React.useMemo(() => {
     return d => {
-      const color = hsl(colorScale(comparator.artifactNames.indexOf(d.key)));
+      const color = hsl(colorScale(artifactNames.indexOf(d.key)));
       if (hoveredArtifacts.length && !hoveredArtifacts.includes(d.key)) {
         color.l = 0.75;
         color.s = 0.4;
       }
       return color.toString();
     };
-  }, [colorScale, comparator.artifactNames, hoveredArtifacts]);
+  }, [colorScale, artifactNames, hoveredArtifacts]);
 
   const areaChart = React.useMemo(() => {
     return (
diff --git a/src/app/src/components/Graph/index.tsx b/src/app/src/components/Graph/index.tsx
index 971dc5d..67113d8 100644
--- a/src/app/src/components/Graph/index.tsx
+++ b/src/app/src/components/Graph/index.tsx
@@ -4,26 +4,19 @@
 import 'd3-transition';
 import Area from './Area';
 import Build from '@build-tracker/build';
-import Comparator from '@build-tracker/comparator';
+import ColorScales from '../../modules/ColorScale';
 import HoverOverlay from './HoverOverlay';
 import { Offset } from './Offset';
 import React from 'react';
+import { ScaleSequential } from 'd3-scale';
 import { stack } from 'd3-shape';
+import { State } from '../../store/types';
 import XAxis from './XAxis';
 import YAxis from './YAxis';
+import { addComparedRevision, setHoveredArtifacts } from '../../store/actions';
 import { createElement, LayoutChangeEvent, StyleSheet, View } from 'react-native';
-import { scaleLinear, scalePoint, ScaleSequential } from 'd3-scale';
-
-interface Props {
-  activeArtifacts: { [key: string]: boolean };
-  colorScale: ScaleSequential<string>;
-  comparator: Comparator;
-  hoveredArtifacts: Array<string>;
-  onHoverArtifacts: (artifactNames: Array<string>) => void;
-  onSelectRevision: (revision: string) => void;
-  selectedRevisions: Array<string>;
-  sizeKey: string;
-}
+import { scaleLinear, scalePoint } from 'd3-scale';
+import { useDispatch, useMappedState } from 'redux-react-hook';
 
 export class SVG extends React.Component<{ height: number; width: number }> {
   public render(): React.ReactElement {
@@ -31,23 +24,36 @@ export class SVG extends React.Component<{ height: number; width: number }> {
   }
 }
 
-const Graph = (props: Props): React.ReactElement => {
-  const {
-    activeArtifacts,
-    colorScale,
-    comparator,
-    hoveredArtifacts,
-    onHoverArtifacts,
-    onSelectRevision,
-    selectedRevisions,
-    sizeKey
-  } = props;
+interface MappedState {
+  activeArtifacts: State['activeArtifacts'];
+  comparator: State['comparator'];
+  colorScale: ScaleSequential<string>;
+  hoveredArtifacts: State['hoveredArtifacts'];
+  selectedRevisions: State['comparedRevisions'];
+  sizeKey: string;
+}
+
+const mapState = (state: State): MappedState => ({
+  activeArtifacts: state.activeArtifacts,
+  comparator: state.comparator,
+  colorScale: ColorScales[state.colorScale].domain([0, state.comparator.artifactNames.length]),
+  hoveredArtifacts: state.hoveredArtifacts,
+  selectedRevisions: state.comparedRevisions,
+  sizeKey: state.sizeKey
+});
+
+const Graph = (): React.ReactElement => {
+  const { activeArtifacts, comparator, colorScale, hoveredArtifacts, selectedRevisions, sizeKey } = useMappedState(
+    mapState
+  );
+  const dispatch = useDispatch();
+
   const [{ width, height }, setDimensions] = React.useState({ width: 0, height: 0 });
   const svgRef = React.useRef(null);
 
-  const activeArtifactNames = React.useMemo(() => Object.keys(activeArtifacts).filter(name => activeArtifacts[name]), [
-    activeArtifacts
-  ]);
+  const activeArtifactNames = React.useMemo(() => {
+    return Object.keys(activeArtifacts).filter(name => activeArtifacts[name]);
+  }, [activeArtifacts]);
 
   const xScale = React.useMemo(() => {
     const domain = comparator.builds
@@ -88,6 +94,20 @@ const Graph = (props: Props): React.ReactElement => {
     setDimensions({ height, width });
   };
 
+  const handleHoverArtifacts = React.useCallback(
+    (artifacts: Array<string>): void => {
+      dispatch(setHoveredArtifacts(artifacts));
+    },
+    [dispatch]
+  );
+
+  const handleSelectRevision = React.useCallback(
+    (revision: string): void => {
+      dispatch(addComparedRevision(revision));
+    },
+    [dispatch]
+  );
+
   return (
     <View onLayout={handleLayout} style={styles.root}>
       <SVG height={height} ref={svgRef} width={width}>
@@ -98,8 +118,8 @@ const Graph = (props: Props): React.ReactElement => {
               <YAxis scale={yScale} />
               <Area
                 activeArtifactNames={activeArtifactNames}
+                artifactNames={comparator.artifactNames}
                 colorScale={colorScale}
-                comparator={comparator}
                 data={data}
                 hoveredArtifacts={hoveredArtifacts}
                 xScale={xScale}
@@ -108,8 +128,8 @@ const Graph = (props: Props): React.ReactElement => {
               <HoverOverlay
                 data={data}
                 height={height - Offset.TOP - Offset.BOTTOM}
-                onHoverArtifacts={onHoverArtifacts}
-                onSelectRevision={onSelectRevision}
+                onHoverArtifacts={handleHoverArtifacts}
+                onSelectRevision={handleSelectRevision}
                 selectedRevisions={selectedRevisions}
                 width={width - Offset.LEFT - Offset.RIGHT}
                 xScale={xScale}
diff --git a/src/app/src/components/SizeKeyPicker/index.tsx b/src/app/src/components/SizeKeyPicker/index.tsx
index 06072fa..92a6630 100644
--- a/src/app/src/components/SizeKeyPicker/index.tsx
+++ b/src/app/src/components/SizeKeyPicker/index.tsx
@@ -2,21 +2,38 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import React from 'react';
+import { setSizeKey } from '../../store/actions';
 import SizeKey from './Key';
+import { State } from '../../store/types';
 import { StyleSheet, View } from 'react-native';
+import { useDispatch, useMappedState } from 'redux-react-hook';
 
 interface Props {
   keys: Array<string>;
-  onSelect: (key: string) => void;
+}
+
+interface MappedState {
   selected: string;
 }
 
+const mapState = (state: State): MappedState => ({
+  selected: state.sizeKey
+});
+
 const SizeKeyPicker = (props: Props): React.ReactElement => {
-  const { keys, onSelect, selected } = props;
+  const { keys } = props;
+  const { selected } = useMappedState(mapState);
+  const dispatch = useDispatch();
+  const handleSelect = React.useCallback(
+    (key: string): void => {
+      dispatch(setSizeKey(key));
+    },
+    [dispatch]
+  );
   return (
     <View style={styles.root}>
       {keys.map(key => (
-        <SizeKey isSelected={selected === key} key={key} onSelect={onSelect} style={styles.button} value={key} />
+        <SizeKey isSelected={selected === key} key={key} onSelect={handleSelect} style={styles.button} value={key} />
       ))}
     </View>
   );
diff --git a/src/app/src/components/Snackbar.tsx b/src/app/src/components/Snackbar.tsx
index fe454ca..45df06b 100644
--- a/src/app/src/components/Snackbar.tsx
+++ b/src/app/src/components/Snackbar.tsx
@@ -2,9 +2,7 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import * as Theme from '../theme';
-import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
 import React from 'react';
-import ReactDOM from 'react-dom';
 import { StyleSheet, Text, View } from 'react-native';
 
 interface Props {
@@ -13,16 +11,12 @@ interface Props {
 
 const Snackbar = (props: Props): React.ReactElement => {
   const { text } = props;
-  const portalRoot = canUseDOM && document.getElementById('snackbarPortal');
-
-  const bar = (
+  return (
     // @ts-ignore
     <View accessibilityRole="alert" style={styles.root}>
       <Text style={styles.text}>{text}</Text>
     </View>
   );
-
-  return portalRoot ? ReactDOM.createPortal(bar, portalRoot) : bar;
 };
 
 const styles = StyleSheet.create({
diff --git a/src/app/src/screens/Main.tsx b/src/app/src/screens/Main.tsx
index 9ce06b0..9f1c93f 100644
--- a/src/app/src/screens/Main.tsx
+++ b/src/app/src/screens/Main.tsx
@@ -2,257 +2,65 @@
  * Copyright (c) 2019 Paul Armstrong
  */
 import * as Theme from '../theme';
-import AppBar from '../components/AppBar';
-import { AppConfig } from '@build-tracker/types';
+import AppBarView from '../views/AppBar';
 import Build from '@build-tracker/build';
-import ColorScale from '../modules/ColorScale';
 import Drawer from '../components/Drawer';
 import DrawerView from '../views/Drawer';
 import { fetch } from 'cross-fetch';
 import Graph from '../components/Graph';
-import MenuIcon from '../icons/Menu';
-import MenuItem from '../components/MenuItem';
 import React from 'react';
-import { ScaleSequential } from 'd3-scale';
-import Snackbar from '../components/Snackbar';
-import { Clipboard, StyleSheet, View } from 'react-native';
-import Comparator, { ArtifactRow } from '@build-tracker/comparator';
+import { setBuilds } from '../store/actions';
+import Snacks from '../views/Snacks';
+import { State } from '../store/types';
+import { StyleSheet, View } from 'react-native';
+import { useDispatch, useMappedState } from 'redux-react-hook';
 
 const Comparison = React.lazy(() => import(/* webpackChunkName: "Comparison" */ '../views/Comparison'));
 
-interface Props {
-  artifactConfig?: AppConfig['artifacts'];
+interface MappedState {
+  buildsCount: number;
+  showComparisonTable: boolean;
   url: string;
 }
 
-const Main = (props: Props): React.ReactElement => {
-  const { artifactConfig = {}, url } = props;
-  const drawerRef: React.RefObject<Drawer> = React.useRef(null);
-  const [compareRevisions, setCompareRevisions] = React.useState<Array<string>>([]);
-
-  const [builds, setBuilds] = React.useState<Array<Build>>([]);
-  const [sizeKey, setSizeKey] = React.useState<string>('');
-  const [activeArtifacts, setActiveArtifacts] = React.useState<{ [key: string]: boolean }>({});
-  React.useEffect(() => {
-    fetch(`${url}/api/builds`)
-      .then(response => response.json())
-      .then(builds => {
-        setBuilds(builds.map(buildStruct => new Build(buildStruct.meta, buildStruct.artifacts)));
-      });
-  }, [url]);
-
-  const comparator = React.useMemo((): Comparator => {
-    const comparator = new Comparator({ builds });
-    setSizeKey(comparator.sizeKeys[0]);
-    setActiveArtifacts(
-      comparator.artifactNames.reduce((memo, artifactName) => {
-        memo[artifactName] = true;
-        return memo;
-      }, {})
-    );
-    return comparator;
-  }, [builds]);
-
-  const activeComparator = React.useMemo(
-    (): Comparator =>
-      new Comparator({
-        artifactBudgets: artifactConfig.budgets,
-        builds: builds.filter(build => compareRevisions.indexOf(build.getMetaValue('revision')) !== -1),
-        groups: artifactConfig.groups
-      }),
-    [artifactConfig.budgets, artifactConfig.groups, builds, compareRevisions]
-  );
-
-  const [colorScale, setColorScale] = React.useState<ScaleSequential<string>>(
-    () => ColorScale[Object.keys(ColorScale)[0]]
-  );
-  const [focusedRevision, setFocusedRevision] = React.useState<string>(null);
-  const [hoveredArtifacts, setHoveredArtifacts] = React.useState<Array<string>>([]);
-  const [disabledArtifactsVisible, setDisabledArtifactsVisible] = React.useState<boolean>(true);
-
-  const [messages, setMessages] = React.useState<Array<string>>([]);
-
-  const [, forceUpdate] = React.useState(0);
-
-  const showDrawer = React.useCallback((): void => {
-    drawerRef.current && drawerRef.current.show();
-  }, []);
-
-  const handleSelectColorScale = React.useCallback((scale): void => {
-    setColorScale(() => scale);
-  }, []);
-
-  const handleDisableArtifacts = React.useCallback((artifactNames: Array<string>): void => {
-    setActiveArtifacts(activeArtifacts => {
-      return Object.entries(activeArtifacts).reduce((memo, [artifactName, isActive]) => {
-        memo[artifactName] = artifactNames.includes(artifactName) ? false : isActive;
-        return memo;
-      }, {});
-    });
-    forceUpdate(Date.now());
-  }, []);
-
-  const handleEnableArtifacts = React.useCallback((artifactNames: Array<string>): void => {
-    setActiveArtifacts(activeArtifacts => {
-      return Object.entries(activeArtifacts).reduce((memo, [artifactName, isActive]) => {
-        memo[artifactName] = artifactNames.includes(artifactName) ? true : isActive;
-        return memo;
-      }, {});
-    });
-    forceUpdate(Date.now());
-  }, []);
-
-  const handleFocusArtifacts = React.useCallback((artifactNames: Array<string>): void => {
-    setActiveArtifacts(activeArtifacts => {
-      return Object.keys(activeArtifacts).reduce((memo, artifactName) => {
-        memo[artifactName] = artifactNames.includes(artifactName) ? true : false;
-        return memo;
-      }, {});
-    });
-    forceUpdate(Date.now());
-  }, []);
-
-  const handleSelectSizeKey = React.useCallback((name: string): void => {
-    setSizeKey(name);
-    forceUpdate(Date.now());
-  }, []);
-
-  const handleSelectRevision = React.useCallback((revision: string): void => {
-    setCompareRevisions(compareRevisions => [...compareRevisions, revision]);
-  }, []);
-
-  const handleClearRevisions = React.useCallback((): void => {
-    setCompareRevisions([]);
-    setFocusedRevision(null);
-  }, []);
-
-  const handleUnfocusRevision = React.useCallback((): void => {
-    setFocusedRevision(null);
-  }, []);
-
-  const handleRemoveRevision = React.useCallback(
-    (revision: string): void => {
-      if (focusedRevision === revision) {
-        setFocusedRevision(null);
-      }
-      setCompareRevisions(compareRevisions => compareRevisions.filter(r => r !== revision));
-    },
-    [focusedRevision]
-  );
-
-  const handleHoverArtifacts = React.useCallback(
-    (newHovered: Array<string>): void => {
-      const equal =
-        newHovered.length === hoveredArtifacts.length &&
-        newHovered.every(value => hoveredArtifacts.indexOf(value) !== -1);
-      if (equal) {
-        return;
-      }
-      setHoveredArtifacts(newHovered);
-    },
-    [hoveredArtifacts]
-  );
-
-  const handleToggleDisabledArtifacts = React.useCallback((showDisabled: boolean): void => {
-    setDisabledArtifactsVisible(showDisabled);
-  }, []);
-
-  const artifactFilter = (row: ArtifactRow): boolean => {
-    const artifactCell = row[0];
-    return activeArtifacts[artifactCell.text];
-  };
+const mapState = (state: State): MappedState => ({
+  buildsCount: state.builds.length,
+  showComparisonTable: !!state.comparedRevisions.length,
+  url: state.url
+});
 
-  const handleCopyAsMarkdown = React.useCallback((): void => {
-    Clipboard.setString(
-      activeComparator.toMarkdown({
-        artifactFilter,
-        sizeKey
-      })
-    );
-    setMessages(messages => [...messages, 'Copied table as markdown'].filter(Boolean));
-  }, [activeComparator, artifactFilter, sizeKey]);
+const Main = (): React.ReactElement => {
+  const drawerRef: React.RefObject<Drawer> = React.useRef(null);
 
-  const handleCopyAsCsv = React.useCallback((): void => {
-    Clipboard.setString(
-      activeComparator.toCsv({
-        artifactFilter,
-        sizeKey
-      })
-    );
-    setMessages(messages => [...messages, 'Copied table as CSV'].filter(Boolean));
-  }, [activeComparator, artifactFilter, sizeKey]);
+  const { buildsCount, showComparisonTable, url } = useMappedState(mapState);
+  const dispatch = useDispatch();
 
   React.useEffect(() => {
-    if (messages.length) {
-      setTimeout(() => {
-        setMessages(messages => messages.slice(1));
-      }, 4000);
+    if (buildsCount === 0) {
+      fetch(`${url}/api/builds`)
+        .then(response => response.json())
+        .then(builds => {
+          dispatch(setBuilds(builds.map(buildStruct => new Build(buildStruct.meta, buildStruct.artifacts))));
+        });
     }
-  }, [messages]);
+  }, [buildsCount, dispatch, url]);
 
   return (
     <View style={styles.layout}>
-      <DrawerView
-        colorScale={colorScale}
-        comparator={comparator}
-        disabledArtifactsVisible={disabledArtifactsVisible}
-        onSelectColorScale={handleSelectColorScale}
-        onSelectSizeKey={handleSelectSizeKey}
-        onToggleDisabledArtifacts={handleToggleDisabledArtifacts}
-        ref={drawerRef}
-        sizeKey={sizeKey}
-      />
+      <DrawerView ref={drawerRef} />
       <View
         // @ts-ignore
         accessibilityRole="main"
         style={styles.main}
       >
         <View style={[styles.column, styles.chart]}>
-          <AppBar
-            navigationIcon={MenuIcon}
-            onPressNavigationIcon={showDrawer}
-            overflowItems={
-              compareRevisions.length ? (
-                <>
-                  <MenuItem key="clear" label="Clear selected revisions" onPress={handleClearRevisions} />
-                  <MenuItem key="md" label="Copy as markdown" onPress={handleCopyAsMarkdown} />
-                  <MenuItem key="csv" label="Copy as CSV" onPress={handleCopyAsCsv} />
-                </>
-              ) : null
-            }
-            title="Build Tracker"
-          />
-          <Graph
-            activeArtifacts={activeArtifacts}
-            colorScale={colorScale}
-            comparator={comparator}
-            hoveredArtifacts={hoveredArtifacts}
-            onHoverArtifacts={handleHoverArtifacts}
-            onSelectRevision={handleSelectRevision}
-            selectedRevisions={compareRevisions}
-            sizeKey={sizeKey}
-          />
+          <AppBarView drawerRef={drawerRef} />
+          <Graph />
         </View>
-        {messages.length ? <Snackbar key={messages[0]} text={messages[0]} /> : null}
-        {compareRevisions.length ? (
+        <Snacks />
+        {showComparisonTable ? (
           <React.Suspense fallback={null}>
-            <Comparison
-              activeArtifacts={activeArtifacts}
-              colorScale={colorScale}
-              comparator={activeComparator}
-              disabledArtifactsVisible={disabledArtifactsVisible}
-              focusedRevision={focusedRevision}
-              hoveredArtifacts={hoveredArtifacts}
-              onDisableArtifacts={handleDisableArtifacts}
-              onEnableArtifacts={handleEnableArtifacts}
-              onFocusArtifacts={handleFocusArtifacts}
-              onFocusRevision={setFocusedRevision}
-              onUnfocusRevision={handleUnfocusRevision}
-              onHoverArtifacts={handleHoverArtifacts}
-              onRemoveRevision={handleRemoveRevision}
-              sizeKey={sizeKey}
-              style={[styles.column, styles.table]}
-            />
+            <Comparison style={[styles.column, styles.table]} />
           </React.Suspense>
         ) : null}
       </View>
diff --git a/src/app/src/server/index.tsx b/src/app/src/server/index.tsx
index 147a280..bda6f8f 100644
--- a/src/app/src/server/index.tsx
+++ b/src/app/src/server/index.tsx
@@ -3,15 +3,32 @@
  */
 import { AppRegistry } from 'react-native-web';
 import Main from '../screens/Main';
+import makeStore from '../store';
+import React from 'react';
 import ReactDOMServer from 'react-dom/server';
+import { Store } from 'redux';
+import { StoreContext } from 'redux-react-hook';
 import toSource from 'tosource';
+import { Actions, State } from '../store/types';
 import { Request, RequestHandler, Response } from 'express';
 
-AppRegistry.registerComponent('App', () => Main);
+interface AppProps {
+  store: Store<State, Actions>;
+}
+
+const App = (props: AppProps): React.ReactElement => (
+  <StoreContext.Provider value={props.store}>
+    <Main />
+  </StoreContext.Provider>
+);
+
+AppRegistry.registerComponent('App', () => App);
+
+export function getPageHTML(nonce: string, state: Partial<State>, scripts: Array<string>): string {
+  const store = makeStore(state);
 
-export function getPageHTML(nonce: string, props: React.ComponentProps<typeof Main>, scripts: Array<string>): string {
   // @ts-ignore
-  const { element, getStyleElement } = AppRegistry.getApplication('App', { initialProps: props });
+  const { element, getStyleElement } = AppRegistry.getApplication('App', { initialProps: { store } });
   const html = ReactDOMServer.renderToString(element);
   const css = ReactDOMServer.renderToStaticMarkup(getStyleElement({ nonce }));
 
@@ -27,7 +44,7 @@ ${css}
 <div id="menuPortal"></div>
 <div id="tooltipPortal"></div>
 <div id="snackbarPortal"></div>
-<script nonce="${nonce}">window.__PROPS__=${toSource(props)}</script>
+<script nonce="${nonce}">window.__PROPS__=${toSource(state)}</script>
 ${scripts.map(script => `<script nonce="${nonce}" src="/client/${script}"></script>`).join('')}
   `;
 }
diff --git a/src/app/src/store/actions.ts b/src/app/src/store/actions.ts
new file mode 100644
index 0000000..c0803a4
--- /dev/null
+++ b/src/app/src/store/actions.ts
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import Build from '@build-tracker/build';
+import ColorScale from '../modules/ColorScale';
+import {
+  AddComparedRevision,
+  AddSnack,
+  ClearComparedRevision,
+  RemoveComparedRevision,
+  RemoveSnack,
+  SetArtifactsActiveAction,
+  SetBuildsAction,
+  SetColorScaleAction,
+  SetDisabledArtifactsVisible,
+  SetFocusedRevision,
+  SetHoveredArtifacts,
+  SetSizeKey
+} from './types';
+
+export const setArtifactActive = (artifacts: Array<string>, active: boolean): SetArtifactsActiveAction => ({
+  type: 'ARTIFACT_SET_ACTIVE',
+  meta: artifacts,
+  payload: active
+});
+
+export const setBuilds = (builds: Array<Build>): SetBuildsAction => ({ type: 'BUILDS_SET', payload: builds });
+
+export const setColorScale = (scale: keyof typeof ColorScale): SetColorScaleAction => ({
+  type: 'COLOR_SCALE_SET',
+  payload: scale
+});
+
+export const addComparedRevision = (revision: string): AddComparedRevision => ({
+  type: 'COMPARED_REVISION_ADD',
+  payload: revision
+});
+
+export const removeComparedRevision = (revision: string): RemoveComparedRevision => ({
+  type: 'COMPARED_REVISION_REMOVE',
+  payload: revision
+});
+
+export const clearComparedRevisions = (): ClearComparedRevision => ({
+  type: 'COMPARED_REVISION_CLEAR',
+  payload: null
+});
+
+export const setDisabledArtifactsVisible = (visible: boolean): SetDisabledArtifactsVisible => ({
+  type: 'SET_DISABLED_ARTIFACTS',
+  payload: visible
+});
+
+export const setFocusedRevision = (revision?: string): SetFocusedRevision => ({
+  type: 'SET_FOCUSED_REVISION',
+  payload: revision
+});
+
+export const addSnack = (message: string): AddSnack => ({
+  type: 'ADD_SNACK',
+  payload: message
+});
+
+export const removeSnack = (message: string): RemoveSnack => ({
+  type: 'REMOVE_SNACK',
+  payload: message
+});
+
+export const setHoveredArtifacts = (artifactNames: Array<string>): SetHoveredArtifacts => ({
+  type: 'HOVER_ARTIFACTS',
+  payload: artifactNames
+});
+
+export const setSizeKey = (key: string): SetSizeKey => ({ type: 'SET_SIZE_KEY', payload: key });
diff --git a/src/app/src/store/index.ts b/src/app/src/store/index.ts
new file mode 100644
index 0000000..8b28586
--- /dev/null
+++ b/src/app/src/store/index.ts
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import ColorScale from '../modules/ColorScale';
+import Comparator from '@build-tracker/comparator';
+import reducer from './reducer';
+import { Actions, State } from './types';
+import { createStore, Store } from 'redux';
+
+export default function makeStore(initialState: Partial<State> = {}): Store<State, Actions> {
+  return createStore(reducer, {
+    activeArtifacts: {},
+    activeComparator: null,
+    artifactConfig: {},
+    builds: [],
+    colorScale: Object.keys(ColorScale)[0],
+    comparator: new Comparator({ builds: [] }),
+    comparedRevisions: [],
+    disabledArtifactsVisible: true,
+    hoveredArtifacts: [],
+    snacks: [],
+    sizeKey: '',
+    ...initialState
+  });
+}
diff --git a/src/app/src/store/mock.ts b/src/app/src/store/mock.ts
new file mode 100644
index 0000000..d517494
--- /dev/null
+++ b/src/app/src/store/mock.ts
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import configureStore from 'redux-mock-store';
+
+const mockStore = configureStore();
+export default mockStore;
diff --git a/src/app/src/store/reducer.ts b/src/app/src/store/reducer.ts
new file mode 100644
index 0000000..2449232
--- /dev/null
+++ b/src/app/src/store/reducer.ts
@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import Comparator from '@build-tracker/comparator';
+import { Actions, State } from './types';
+
+const getActiveComparator = (
+  comparedRevisions: State['comparedRevisions'],
+  builds: State['builds'],
+  artifactConfig: State['artifactConfig']
+): Comparator => {
+  return new Comparator({
+    artifactBudgets: artifactConfig.budgets,
+    builds: builds.filter(build => comparedRevisions.includes(build.getMetaValue('revision'))),
+    groups: artifactConfig.groups
+  });
+};
+
+export default function reducer(state: State, action: Actions): State {
+  switch (action.type) {
+    case 'ARTIFACT_SET_ACTIVE': {
+      const activeArtifacts = action.meta.reduce(
+        (memo, artifactName) => {
+          memo[artifactName] = action.payload;
+          return memo;
+        },
+        { ...state.activeArtifacts }
+      );
+      return { ...state, activeArtifacts };
+    }
+
+    case 'BUILDS_SET': {
+      const builds = action.payload;
+      const comparator = new Comparator({ builds });
+      const activeArtifacts = comparator.artifactNames.reduce((memo, artifactName) => {
+        memo[artifactName] = true;
+        return memo;
+      }, {});
+      return { ...state, activeArtifacts, builds, comparator, sizeKey: comparator.sizeKeys[0] };
+    }
+
+    case 'COLOR_SCALE_SET':
+      return { ...state, colorScale: action.payload };
+
+    case 'COMPARED_REVISION_ADD': {
+      const comparedRevisions = [...state.comparedRevisions, action.payload];
+      return {
+        ...state,
+        activeComparator: getActiveComparator(comparedRevisions, state.builds, state.artifactConfig),
+        comparedRevisions
+      };
+    }
+
+    case 'COMPARED_REVISION_REMOVE': {
+      const comparedRevisions = state.comparedRevisions.filter(rev => rev !== action.payload);
+      return {
+        ...state,
+        activeComparator: getActiveComparator(comparedRevisions, state.builds, state.artifactConfig),
+        comparedRevisions,
+        focusedRevision: state.focusedRevision === action.payload ? undefined : state.focusedRevision
+      };
+    }
+
+    case 'COMPARED_REVISION_CLEAR':
+      return { ...state, activeComparator: null, comparedRevisions: [], focusedRevision: undefined };
+
+    case 'SET_FOCUSED_REVISION':
+      return { ...state, focusedRevision: action.payload || undefined };
+
+    case 'SET_DISABLED_ARTIFACTS':
+      return { ...state, disabledArtifactsVisible: action.payload };
+
+    case 'SET_SIZE_KEY':
+      return { ...state, sizeKey: action.payload };
+
+    case 'ADD_SNACK':
+      return { ...state, snacks: [...state.snacks, action.payload] };
+
+    case 'REMOVE_SNACK':
+      return { ...state, snacks: state.snacks.filter(msg => msg !== action.payload) };
+
+    case 'HOVER_ARTIFACTS':
+      if (
+        state.hoveredArtifacts.length === action.payload.length &&
+        action.payload.every(value => state.hoveredArtifacts.includes(value))
+      ) {
+        return state;
+      }
+      return { ...state, hoveredArtifacts: action.payload };
+
+    default:
+      return state;
+  }
+}
diff --git a/src/app/src/store/types.ts b/src/app/src/store/types.ts
new file mode 100644
index 0000000..4cc51a6
--- /dev/null
+++ b/src/app/src/store/types.ts
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import { AppConfig } from '@build-tracker/types';
+import Build from '@build-tracker/build';
+import ColorScale from '../modules/ColorScale';
+import Comparator from '@build-tracker/comparator';
+
+export interface State {
+  activeArtifacts: { [key: string]: boolean };
+  activeComparator: Comparator;
+  artifactConfig: AppConfig['artifacts'];
+  builds: Array<Build>;
+  colorScale: keyof typeof ColorScale;
+  comparator: Comparator;
+  comparedRevisions: Array<string>;
+  disabledArtifactsVisible: boolean;
+  focusedRevision?: string;
+  hoveredArtifacts: Array<string>;
+  snacks: Array<string>;
+  sizeKey: string;
+  url: string;
+}
+
+interface Action<T, P, M = undefined> {
+  type: T;
+  payload: P;
+  meta?: M;
+}
+export type SetArtifactsActiveAction = Action<'ARTIFACT_SET_ACTIVE', boolean, Array<string>>;
+export type SetBuildsAction = Action<'BUILDS_SET', Array<Build>>;
+export type SetColorScaleAction = Action<'COLOR_SCALE_SET', keyof typeof ColorScale>;
+export type AddComparedRevision = Action<'COMPARED_REVISION_ADD', string>;
+export type RemoveComparedRevision = Action<'COMPARED_REVISION_REMOVE', string>;
+export type ClearComparedRevision = Action<'COMPARED_REVISION_CLEAR', void>;
+export type SetDisabledArtifactsVisible = Action<'SET_DISABLED_ARTIFACTS', boolean>;
+export type SetFocusedRevision = Action<'SET_FOCUSED_REVISION', string | void>;
+export type SetSizeKey = Action<'SET_SIZE_KEY', string>;
+export type AddSnack = Action<'ADD_SNACK', string>;
+export type RemoveSnack = Action<'REMOVE_SNACK', string>;
+export type SetHoveredArtifacts = Action<'HOVER_ARTIFACTS', Array<string>>;
+
+export type Actions =
+  | SetArtifactsActiveAction
+  | SetBuildsAction
+  | SetColorScaleAction
+  | AddComparedRevision
+  | RemoveComparedRevision
+  | ClearComparedRevision
+  | SetDisabledArtifactsVisible
+  | SetFocusedRevision
+  | SetSizeKey
+  | AddSnack
+  | RemoveSnack
+  | SetHoveredArtifacts;
diff --git a/src/app/src/views/AppBar.tsx b/src/app/src/views/AppBar.tsx
new file mode 100644
index 0000000..af7aaa2
--- /dev/null
+++ b/src/app/src/views/AppBar.tsx
@@ -0,0 +1,91 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import AppBar from '../components/AppBar';
+import { ArtifactRow } from '@build-tracker/comparator';
+import { Clipboard } from 'react-native';
+import Drawer from '../components/Drawer';
+import MenuIcon from '../icons/Menu';
+import MenuItem from '../components/MenuItem';
+import React from 'react';
+import { State } from '../store/types';
+import { addSnack, clearComparedRevisions } from '../store/actions';
+import { useDispatch, useMappedState } from 'redux-react-hook';
+
+interface MappedState {
+  activeComparator: State['activeComparator'];
+  activeArtifacts: State['activeArtifacts'];
+  comparator: State['comparator'];
+  comparedRevisions: State['comparedRevisions'];
+  sizeKey: string;
+  url: string;
+}
+
+const mapState = (state: State): MappedState => {
+  return {
+    activeComparator: state.activeComparator,
+    activeArtifacts: state.activeArtifacts,
+    comparator: state.comparator,
+    comparedRevisions: state.comparedRevisions,
+    sizeKey: state.sizeKey || state.comparator.sizeKeys[0],
+    url: state.url
+  };
+};
+
+const AppBarView = (props: { drawerRef: React.RefObject<Drawer> }): React.ReactElement => {
+  const { drawerRef } = props;
+  const { activeArtifacts, activeComparator, comparedRevisions, sizeKey } = useMappedState(mapState);
+  const dispatch = useDispatch();
+
+  const showDrawer = React.useCallback((): void => {
+    drawerRef.current && drawerRef.current.show();
+  }, [drawerRef]);
+
+  const handleClearRevisions = React.useCallback((): void => {
+    dispatch(clearComparedRevisions());
+  }, [dispatch]);
+
+  const artifactFilter = (row: ArtifactRow): boolean => {
+    const artifactCell = row[0];
+    return activeArtifacts[artifactCell.text];
+  };
+
+  const handleCopyAsMarkdown = React.useCallback((): void => {
+    Clipboard.setString(
+      activeComparator.toMarkdown({
+        artifactFilter,
+        sizeKey
+      })
+    );
+    dispatch(addSnack('Copied table as markdown'));
+  }, [activeComparator, artifactFilter, dispatch, sizeKey]);
+
+  const handleCopyAsCsv = React.useCallback((): void => {
+    Clipboard.setString(
+      activeComparator.toCsv({
+        artifactFilter,
+        sizeKey
+      })
+    );
+    dispatch(addSnack('Copied table as CSV'));
+  }, [activeComparator, artifactFilter, dispatch, sizeKey]);
+
+  return (
+    <AppBar
+      navigationIcon={MenuIcon}
+      onPressNavigationIcon={showDrawer}
+      overflowItems={
+        comparedRevisions.length ? (
+          <>
+            <MenuItem key="clear" label="Clear selected revisions" onPress={handleClearRevisions} />
+            <MenuItem key="md" label="Copy as markdown" onPress={handleCopyAsMarkdown} />
+            <MenuItem key="csv" label="Copy as CSV" onPress={handleCopyAsCsv} />
+          </>
+        ) : null
+      }
+      title="Build Tracker"
+    />
+  );
+};
+
+export default AppBarView;
diff --git a/src/app/src/views/Comparison.tsx b/src/app/src/views/Comparison.tsx
index e0095a4..d69d307 100644
--- a/src/app/src/views/Comparison.tsx
+++ b/src/app/src/views/Comparison.tsx
@@ -3,34 +3,100 @@
  */
 import * as Theme from '../theme';
 import BuildInfo from '../components/BuildInfo';
+import ColorScales from '../modules/ColorScale';
 import ComparisonTable from '../components/ComparisonTable';
 import React from 'react';
+import { State } from '../store/types';
+import { removeComparedRevision, setArtifactActive, setFocusedRevision, setHoveredArtifacts } from '../store/actions';
 import { ScrollView, StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
+import { useDispatch, useMappedState } from 'redux-react-hook';
 
-interface Props extends React.ComponentProps<typeof ComparisonTable> {
-  focusedRevision: string;
-  onUnfocusRevision: () => void;
+interface Props {
   style?: StyleProp<ViewStyle>;
 }
 
+interface MappedState {
+  activeArtifacts: State['activeArtifacts'];
+  colorScaleName: State['colorScale'];
+  comparator: State['comparator'];
+  disabledArtifactsVisible: boolean;
+  focusedRevision: string;
+  hoveredArtifacts: Array<string>;
+  sizeKey: string;
+}
+
+const mapState = (state: State): MappedState => ({
+  activeArtifacts: state.activeArtifacts,
+  colorScaleName: state.colorScale,
+  comparator: state.activeComparator,
+  disabledArtifactsVisible: state.disabledArtifactsVisible,
+  focusedRevision: state.focusedRevision,
+  hoveredArtifacts: state.hoveredArtifacts,
+  sizeKey: state.sizeKey
+});
+
 const Comparison = (props: Props): React.ReactElement => {
+  const { style } = props;
+
   const {
     activeArtifacts,
-    colorScale,
+    colorScaleName,
     comparator,
     disabledArtifactsVisible,
     focusedRevision,
     hoveredArtifacts,
-    onDisableArtifacts,
-    onEnableArtifacts,
-    onFocusArtifacts,
-    onFocusRevision,
-    onHoverArtifacts,
-    onRemoveRevision,
-    onUnfocusRevision,
-    sizeKey,
-    style
-  } = props;
+    sizeKey
+  } = useMappedState(mapState);
+  const dispatch = useDispatch();
+
+  const colorScale = React.useMemo(() => ColorScales[colorScaleName].domain([0, comparator.artifactNames.length]), [
+    colorScaleName,
+    comparator.artifactNames.length
+  ]);
+
+  const handleDisableArtifacts = React.useCallback(
+    (artifactNames: Array<string>): void => {
+      dispatch(setArtifactActive(artifactNames, false));
+    },
+    [dispatch]
+  );
+
+  const handleEnableArtifacts = React.useCallback(
+    (artifactNames: Array<string>): void => {
+      dispatch(setArtifactActive(artifactNames, true));
+    },
+    [dispatch]
+  );
+
+  const handleFocusArtifacts = React.useCallback(
+    (artifactNames: Array<string>): void => {
+      dispatch(setArtifactActive(artifactNames, true));
+      dispatch(setArtifactActive(Object.keys(activeArtifacts).filter(name => !artifactNames.includes(name)), false));
+    },
+    [activeArtifacts, dispatch]
+  );
+
+  const handleFocusRevision = React.useCallback(
+    (revision: string): void => {
+      dispatch(setFocusedRevision(revision));
+    },
+    [dispatch]
+  );
+
+  const handleRemoveRevision = React.useCallback(
+    (revision: string): void => {
+      dispatch(removeComparedRevision(revision));
+    },
+    [dispatch]
+  );
+
+  const handleHoverArtifacts = React.useCallback(
+    (artifacts: Array<string>): void => {
+      dispatch(setHoveredArtifacts(artifacts));
+    },
+    [dispatch]
+  );
+
   return (
     <View style={style}>
       <ScrollView horizontal style={styles.tableScroll}>
@@ -41,12 +107,12 @@ const Comparison = (props: Props): React.ReactElement => {
             comparator={comparator}
             disabledArtifactsVisible={disabledArtifactsVisible}
             hoveredArtifacts={hoveredArtifacts}
-            onDisableArtifacts={onDisableArtifacts}
-            onEnableArtifacts={onEnableArtifacts}
-            onFocusArtifacts={onFocusArtifacts}
-            onFocusRevision={onFocusRevision}
-            onHoverArtifacts={onHoverArtifacts}
-            onRemoveRevision={onRemoveRevision}
+            onDisableArtifacts={handleDisableArtifacts}
+            onEnableArtifacts={handleEnableArtifacts}
+            onFocusArtifacts={handleFocusArtifacts}
+            onFocusRevision={handleFocusRevision}
+            onHoverArtifacts={handleHoverArtifacts}
+            onRemoveRevision={handleRemoveRevision}
             sizeKey={sizeKey}
           />
         </ScrollView>
@@ -54,10 +120,7 @@ const Comparison = (props: Props): React.ReactElement => {
       {focusedRevision ? (
         <React.Suspense fallback={null}>
           <View style={styles.buildInfo} testID="buildinfo">
-            <BuildInfo
-              build={comparator.builds.find(build => build.getMetaValue('revision') === focusedRevision)}
-              onClose={onUnfocusRevision}
-            />
+            <BuildInfo focusedRevision={focusedRevision} />
           </View>
         </React.Suspense>
       ) : null}
diff --git a/src/app/src/views/Drawer.tsx b/src/app/src/views/Drawer.tsx
index da2e9e2..f9b2cf1 100644
--- a/src/app/src/views/Drawer.tsx
+++ b/src/app/src/views/Drawer.tsx
@@ -3,38 +3,40 @@
  */
 import * as Theme from '../theme';
 import ColorScalePicker from '../components/ColorScalePicker';
-import Comparator from '@build-tracker/comparator';
 import Divider from '../components/Divider';
 import Drawer from '../components/Drawer';
 import DrawerLink from '../components/DrawerLink';
 import HeartIcon from '../icons/Heart';
 import OpenInExternalIcon from '../icons/OpenInExternal';
 import React from 'react';
-import { ScaleSequential } from 'd3-scale';
+import { setDisabledArtifactsVisible } from '../store/actions';
 import SizeKeyPicker from '../components/SizeKeyPicker';
+import { State } from '../store/types';
 import Subtitle from '../components/Subtitle';
 import { StyleSheet, Switch, Text, View } from 'react-native';
+import { useDispatch, useMappedState } from 'redux-react-hook';
 
-interface Props {
-  colorScale: ScaleSequential<string>;
-  comparator: Comparator;
-  disabledArtifactsVisible: boolean;
-  onSelectColorScale: (scale: ScaleSequential<string>) => void;
-  onSelectSizeKey: (sizeKey: string) => void;
-  onToggleDisabledArtifacts: (showDisabled: boolean) => void;
-  sizeKey: string;
+interface MappedState {
+  comparator: State['comparator'];
+  disabledArtifactsVisible: State['disabledArtifactsVisible'];
 }
 
-const DrawerView = (props: Props, ref: React.RefObject<Drawer>): React.ReactElement => {
-  const {
-    colorScale,
-    comparator,
-    disabledArtifactsVisible,
-    onSelectColorScale,
-    onSelectSizeKey,
-    onToggleDisabledArtifacts,
-    sizeKey
-  } = props;
+const mapState = (state: State): MappedState => ({
+  comparator: state.comparator,
+  disabledArtifactsVisible: state.disabledArtifactsVisible
+});
+
+const DrawerView = (_props: {}, ref: React.RefObject<Drawer>): React.ReactElement => {
+  const { comparator, disabledArtifactsVisible } = useMappedState(mapState);
+  const dispatch = useDispatch();
+
+  const handleToggleDisabled = React.useCallback(
+    (showDisabled: boolean): void => {
+      dispatch(setDisabledArtifactsVisible(showDisabled));
+    },
+    [dispatch]
+  );
+
   return (
     <Drawer hidden ref={ref}>
       <View style={styles.header}>
@@ -42,7 +44,7 @@ const DrawerView = (props: Props, ref: React.RefObject<Drawer>): React.ReactElem
       </View>
       <Divider />
       <Subtitle title="Compare artifacts by" />
-      <SizeKeyPicker keys={comparator.sizeKeys} onSelect={onSelectSizeKey} selected={sizeKey} />
+      <SizeKeyPicker keys={comparator.sizeKeys} />
       <Divider />
       <View style={styles.switchRoot}>
         {
@@ -50,7 +52,7 @@ const DrawerView = (props: Props, ref: React.RefObject<Drawer>): React.ReactElem
           <Switch
             activeThumbColor={Theme.Color.Primary30}
             activeTrackColor={Theme.Color.Primary00}
-            onValueChange={onToggleDisabledArtifacts}
+            onValueChange={handleToggleDisabled}
             style={styles.switch}
             value={disabledArtifactsVisible}
           />
@@ -59,7 +61,7 @@ const DrawerView = (props: Props, ref: React.RefObject<Drawer>): React.ReactElem
       </View>
       <Divider />
       <Subtitle title="Color scale" />
-      <ColorScalePicker activeColorScale={colorScale} onSelect={onSelectColorScale} />
+      <ColorScalePicker />
       <Divider />
       <View style={styles.footer}>
         <Subtitle title="Links" />
diff --git a/src/app/src/views/Snacks.tsx b/src/app/src/views/Snacks.tsx
new file mode 100644
index 0000000..2389b2a
--- /dev/null
+++ b/src/app/src/views/Snacks.tsx
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { removeSnack } from '../store/actions';
+import Snackbar from '../components/Snackbar';
+import { State } from '../store/types';
+import { useDispatch, useMappedState } from 'redux-react-hook';
+
+interface MappedState {
+  message: string;
+}
+
+const mapState = (state: State): MappedState => ({
+  message: state.snacks[0]
+});
+
+const SnacksView = (): React.ReactElement => {
+  const dispatch = useDispatch();
+  const { message } = useMappedState(mapState);
+
+  const portalRoot = canUseDOM && document.getElementById('snackbarPortal');
+
+  React.useEffect(() => {
+    if (message) {
+      setTimeout(() => {
+        dispatch(removeSnack(message));
+      }, 4000);
+    }
+  }, [dispatch, message]);
+
+  const bar = message ? <Snackbar text={message} /> : null;
+
+  return portalRoot ? ReactDOM.createPortal(bar, portalRoot) : bar;
+};
+
+export default SnacksView;
diff --git a/yarn.lock b/yarn.lock
index ca6711e..cb00671 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6200,6 +6200,11 @@ lodash.get@^4.4.2:
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
   integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
 
+lodash.isplainobject@^4.0.6:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
+
 lodash.set@^4.3.2:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
@@ -8295,6 +8300,26 @@ redent@^2.0.0:
     indent-string "^3.0.0"
     strip-indent "^2.0.0"
 
+redux-mock-store@^1.5.3:
+  version "1.5.3"
+  resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.3.tgz#1f10528949b7ce8056c2532624f7cafa98576c6d"
+  integrity sha512-ryhkkb/4D4CUGpAV2ln1GOY/uh51aczjcRz9k2L2bPx/Xja3c5pSGJJPyR25GNVRXtKIExScdAgFdiXp68GmJA==
+  dependencies:
+    lodash.isplainobject "^4.0.6"
+
+redux-react-hook@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/redux-react-hook/-/redux-react-hook-3.2.1.tgz#41bc93bc09ebbe9e914aa0fa186a128fd3e8d0fb"
+  integrity sha512-xTXhFajTVeomc6GwTsTvUKMtv58511MY02bPSbKZGOuSO9+c3cW7lDybHlkwLU90kryX0cC+Vk/ukgJC4dXOPQ==
+
+redux@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.1.tgz#436cae6cc40fbe4727689d7c8fae44808f1bfef5"
+  integrity sha512-R7bAtSkk7nY6O/OYMVR9RiBI+XghjF9rlbl5806HJbQph0LJVHZrU5oaO4q70eUKiqMRqm4y07KLTlMZ2BlVmg==
+  dependencies:
+    loose-envify "^1.4.0"
+    symbol-observable "^1.2.0"
+
 referrer-policy@1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.1.0.tgz#35774eb735bf50fb6c078e83334b472350207d79"
@@ -9268,7 +9293,7 @@ supports-color@^6.0.0, supports-color@^6.1.0:
   dependencies:
     has-flag "^3.0.0"
 
-symbol-observable@^1.1.0:
+symbol-observable@^1.1.0, symbol-observable@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
   integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==


Test patch:
diff --git a/src/app/src/components/ColorScalePicker/__tests__/ColorScale.test.tsx b/src/app/src/components/ColorScalePicker/__tests__/ColorScale.test.tsx
index f2b6066..53e561e 100644
--- a/src/app/src/components/ColorScalePicker/__tests__/ColorScale.test.tsx
+++ b/src/app/src/components/ColorScalePicker/__tests__/ColorScale.test.tsx
@@ -1,16 +1,22 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
+import * as Actions from '../../../store/actions';
 import { ColorScale } from '../ColorScale';
-import ColorScales from '../../../modules/ColorScale';
+import mockStore from '../../../store/mock';
 import RadioSelect from '../../RadioSelect';
 import React from 'react';
+import { StoreContext } from 'redux-react-hook';
 import { fireEvent, render } from 'react-native-testing-library';
 
 describe('ColorScale', () => {
   describe('rendering', () => {
     test('wraps the radio in a label', () => {
-      const { queryAllByProps } = render(<ColorScale name="tacos" onSelect={jest.fn()} scale={ColorScales.Magma} />);
+      const { queryAllByProps } = render(
+        <StoreContext.Provider value={mockStore()}>
+          <ColorScale name="tacos" />
+        </StoreContext.Provider>
+      );
       expect(queryAllByProps({ accessibilityRole: 'label' })).toHaveLength(1);
     });
   });
@@ -18,28 +24,42 @@ describe('ColorScale', () => {
   describe('isSelected', () => {
     test('sets checkbox value when true', () => {
       const { getByType } = render(
-        <ColorScale isSelected name="tacos" onSelect={jest.fn()} scale={ColorScales.Magma} />
+        <StoreContext.Provider value={mockStore()}>
+          <ColorScale isSelected name="tacos" />
+        </StoreContext.Provider>
       );
       expect(getByType(RadioSelect).props.value).toBe(true);
     });
 
     test('does not set checkbox value when false', () => {
-      const { getByType } = render(<ColorScale name="tacos" onSelect={jest.fn()} scale={ColorScales.Magma} />);
+      const { getByType } = render(
+        <StoreContext.Provider value={mockStore()}>
+          <ColorScale name="tacos" />
+        </StoreContext.Provider>
+      );
       expect(getByType(RadioSelect).props.value).toBeUndefined();
     });
   });
 
   describe('onSelect', () => {
     test('fires onSelect with the value', () => {
-      const handleOnSelect = jest.fn();
-      const { getByType } = render(<ColorScale onSelect={handleOnSelect} name="tacos" scale={ColorScales.Rainbow} />);
+      const handleOnSelect = jest.spyOn(Actions, 'setColorScale');
+      const { getByType } = render(
+        <StoreContext.Provider value={mockStore()}>
+          <ColorScale name="Rainbow" />
+        </StoreContext.Provider>
+      );
       fireEvent(getByType(RadioSelect), 'valueChange', true);
-      expect(handleOnSelect).toHaveBeenCalledWith(ColorScales.Rainbow);
+      expect(handleOnSelect).toHaveBeenCalledWith('Rainbow');
     });
 
     test('does not fire onSelect when value changes to false', () => {
-      const handleOnSelect = jest.fn();
-      const { getByType } = render(<ColorScale onSelect={handleOnSelect} name="tacos" scale={ColorScales.Rainbow} />);
+      const handleOnSelect = jest.spyOn(Actions, 'setColorScale');
+      const { getByType } = render(
+        <StoreContext.Provider value={mockStore()}>
+          <ColorScale name="tacos" />
+        </StoreContext.Provider>
+      );
       fireEvent(getByType(RadioSelect), 'valueChange', false);
       expect(handleOnSelect).not.toHaveBeenCalled();
     });
diff --git a/src/app/src/components/ColorScalePicker/__tests__/index.test.tsx b/src/app/src/components/ColorScalePicker/__tests__/index.test.tsx
index 4737b2a..c94d9c5 100644
--- a/src/app/src/components/ColorScalePicker/__tests__/index.test.tsx
+++ b/src/app/src/components/ColorScalePicker/__tests__/index.test.tsx
@@ -1,17 +1,21 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
-import ColorScale from '../../../modules/ColorScale';
-import ColorScaleComponent from '../ColorScale';
 import { ColorScalePicker } from '../';
+import mockStore from '../../../store/mock';
 import React from 'react';
-import { fireEvent, render } from 'react-native-testing-library';
+import { render } from 'react-native-testing-library';
+import { StoreContext } from 'redux-react-hook';
 
 describe('ColorScalePicker', () => {
-  test('passes onSelect through', () => {
-    const handleSelect = jest.fn();
-    const { queryAllByType } = render(<ColorScalePicker activeColorScale={ColorScale.Magma} onSelect={handleSelect} />);
-    fireEvent(queryAllByType(ColorScaleComponent)[0], 'select', ColorScale.Rainbow);
-    expect(handleSelect).toHaveBeenCalledWith(ColorScale.Rainbow);
+  test('sets the active scale to selected', () => {
+    const { queryAllByProps } = render(
+      <StoreContext.Provider value={mockStore({ colorScale: 'Magma' })}>
+        <ColorScalePicker />
+      </StoreContext.Provider>
+    );
+    const selected = queryAllByProps({ isSelected: true });
+    expect(selected).toHaveLength(1);
+    expect(selected[0].props.name).toBe('Magma');
   });
 });
diff --git a/src/app/src/components/ComparisonTable/__tests__/ComparisonTable.test.tsx b/src/app/src/components/ComparisonTable/__tests__/ComparisonTable.test.tsx
index 16849d3..7289cab 100644
--- a/src/app/src/components/ComparisonTable/__tests__/ComparisonTable.test.tsx
+++ b/src/app/src/components/ComparisonTable/__tests__/ComparisonTable.test.tsx
@@ -28,8 +28,8 @@ describe('ComparisonTable', () => {
       const { queryAllByProps } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: false, main: true }}
-          colorScale={ColorScale.Magma}
           comparator={comparator}
+          colorScale={ColorScale.Cool}
           disabledArtifactsVisible={false}
           hoveredArtifacts={[]}
           onDisableArtifacts={jest.fn()}
@@ -51,8 +51,8 @@ describe('ComparisonTable', () => {
       const { queryAllByProps } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: false, main: false }}
-          colorScale={ColorScale.Magma}
           comparator={comparator}
+          colorScale={ColorScale.Cool}
           disabledArtifactsVisible={false}
           hoveredArtifacts={[]}
           onDisableArtifacts={jest.fn()}
@@ -76,8 +76,8 @@ describe('ComparisonTable', () => {
       const { getByProps } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: true, main: true }}
-          colorScale={ColorScale.Magma}
           comparator={comparator}
+          colorScale={ColorScale.Cool}
           disabledArtifactsVisible
           hoveredArtifacts={[]}
           onDisableArtifacts={handleDisableArtifacts}
@@ -99,8 +99,8 @@ describe('ComparisonTable', () => {
       const { getByProps } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: true, main: false }}
-          colorScale={ColorScale.Magma}
           comparator={comparator}
+          colorScale={ColorScale.Cool}
           disabledArtifactsVisible
           hoveredArtifacts={[]}
           onDisableArtifacts={jest.fn()}
@@ -122,8 +122,8 @@ describe('ComparisonTable', () => {
       const { getByProps } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: true, main: false }}
-          colorScale={ColorScale.Magma}
           comparator={comparator}
+          colorScale={ColorScale.Cool}
           disabledArtifactsVisible
           hoveredArtifacts={[]}
           onDisableArtifacts={jest.fn()}
@@ -147,8 +147,8 @@ describe('ComparisonTable', () => {
       const { queryAllByType } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: false, main: false }}
-          colorScale={ColorScale.Magma}
           comparator={comparator}
+          colorScale={ColorScale.Cool}
           disabledArtifactsVisible
           hoveredArtifacts={[]}
           onDisableArtifacts={jest.fn()}
@@ -170,8 +170,8 @@ describe('ComparisonTable', () => {
       const { getByType } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: false, main: false }}
-          colorScale={ColorScale.Magma}
           comparator={comparator}
+          colorScale={ColorScale.Cool}
           disabledArtifactsVisible
           hoveredArtifacts={[]}
           onDisableArtifacts={jest.fn()}
@@ -193,8 +193,8 @@ describe('ComparisonTable', () => {
       const { queryAllByType } = render(
         <ComparisonTable
           activeArtifacts={{ vendor: false, main: false }}
-          colorScale={ColorScale.Magma}
           comparator={comparator}
+          colorScale={ColorScale.Cool}
           disabledArtifactsVisible
           hoveredArtifacts={[]}
           onDisableArtifacts={jest.fn()}
diff --git a/src/app/src/components/Graph/__tests__/Area.test.tsx b/src/app/src/components/Graph/__tests__/Area.test.tsx
index d9890d9..683cfdf 100644
--- a/src/app/src/components/Graph/__tests__/Area.test.tsx
+++ b/src/app/src/components/Graph/__tests__/Area.test.tsx
@@ -43,8 +43,8 @@ describe('Area', () => {
       <svg>
         <Area
           activeArtifactNames={['main']}
-          colorScale={ColorScale.Rainbow}
-          comparator={comparator}
+          artifactNames={comparator.artifactNames}
+          colorScale={ColorScale.Cool}
           data={data}
           hoveredArtifacts={[]}
           xScale={xScale}
@@ -88,8 +88,8 @@ describe('Area', () => {
       <svg>
         <Area
           activeArtifactNames={['main', 'vendor']}
-          colorScale={ColorScale.Rainbow}
-          comparator={comparator}
+          artifactNames={comparator.artifactNames}
+          colorScale={ColorScale.Cool}
           data={data}
           hoveredArtifacts={[]}
           xScale={xScale}
@@ -137,8 +137,8 @@ describe('Area', () => {
       <svg>
         <Area
           activeArtifactNames={['main', 'vendor']}
-          colorScale={ColorScale.Rainbow}
-          comparator={comparator}
+          artifactNames={comparator.artifactNames}
+          colorScale={ColorScale.Cool}
           data={data}
           hoveredArtifacts={['vendor']}
           xScale={xScale}
diff --git a/src/app/src/components/Graph/__tests__/index.test.tsx b/src/app/src/components/Graph/__tests__/index.test.tsx
index 93b65b7..6827e0b 100644
--- a/src/app/src/components/Graph/__tests__/index.test.tsx
+++ b/src/app/src/components/Graph/__tests__/index.test.tsx
@@ -1,14 +1,14 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
-import { act } from 'react-dom/test-utils';
 import Build from '@build-tracker/build';
 import buildDataA from '@build-tracker/fixtures/builds/30af629d1d4c9f2f199cec5f572a019d4198004c.json';
 import buildDataB from '@build-tracker/fixtures/builds/22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04.json';
 import buildDataC from '@build-tracker/fixtures/builds/243024909db66ac3c3e48d2ffe4015f049609834.json';
-import ColorScale from '../../../modules/ColorScale';
 import Comparator from '@build-tracker/comparator';
+import mockStore from '../../../store/mock';
 import React from 'react';
+import { StoreContext } from 'redux-react-hook';
 import { View } from 'react-native';
 import { fireEvent, render } from 'react-native-testing-library';
 import Graph, { SVG } from '../';
@@ -23,20 +23,20 @@ describe('Graph', () => {
   test('resizes the SVG after layout', () => {
     const comparator = new Comparator({ builds: builds });
     const { getByType } = render(
-      <Graph
-        activeArtifacts={{ main: true }}
-        colorScale={ColorScale.Magma}
-        comparator={comparator}
-        hoveredArtifacts={[]}
-        onHoverArtifacts={jest.fn()}
-        onSelectRevision={jest.fn()}
-        selectedRevisions={[]}
-        sizeKey="stat"
-      />
+      <StoreContext.Provider
+        value={mockStore({
+          activeArtifacts: { main: true },
+          colorScale: 'Magma',
+          comparator,
+          comparedRevisions: [],
+          hoveredArtifacts: [],
+          sizeKey: 'stat'
+        })}
+      >
+        <Graph />
+      </StoreContext.Provider>
     );
-    act(() => {
-      fireEvent(getByType(View), 'layout', { nativeEvent: { layout: { width: 400, height: 300 } } });
-    });
+    fireEvent(getByType(View), 'layout', { nativeEvent: { layout: { width: 400, height: 300 } } });
     const svg = getByType(SVG);
     expect(svg.props.width).toEqual(400);
     expect(svg.props.height).toEqual(300);
diff --git a/src/app/src/components/SizeKeyPicker/__tests__/index.test.tsx b/src/app/src/components/SizeKeyPicker/__tests__/index.test.tsx
index 848c54a..2b523de 100644
--- a/src/app/src/components/SizeKeyPicker/__tests__/index.test.tsx
+++ b/src/app/src/components/SizeKeyPicker/__tests__/index.test.tsx
@@ -1,25 +1,35 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
+import * as Actions from '../../../store/actions';
+import mockStore from '../../../store/mock';
 import React from 'react';
 import SizeKeyPicker from '../';
+import { StoreContext } from 'redux-react-hook';
 import { fireEvent, render } from 'react-native-testing-library';
 
 describe('SizeKeyPicker', () => {
   test('renders a button per key', () => {
-    const { queryAllByProps } = render(<SizeKeyPicker keys={['foo', 'bar']} onSelect={jest.fn()} selected="foo" />);
+    const { queryAllByProps } = render(
+      <StoreContext.Provider value={mockStore({ sizeKey: 'foo' })}>
+        <SizeKeyPicker keys={['foo', 'bar']} />
+      </StoreContext.Provider>
+    );
     expect(queryAllByProps({ isSelected: true, value: 'foo' })).toHaveLength(1);
     expect(queryAllByProps({ isSelected: false, value: 'bar' })).toHaveLength(1);
   });
 
   test('passes the onSelect handler', () => {
-    const handleSelect = jest.fn();
+    const handleSelect = jest.spyOn(Actions, 'setSizeKey');
     const { queryAllByProps } = render(
-      <SizeKeyPicker keys={['foo', 'bar', 'tacos']} onSelect={handleSelect} selected="foo" />
+      <StoreContext.Provider value={mockStore({ sizeKey: 'foo' })}>
+        <SizeKeyPicker keys={['foo', 'bar', 'tacos']} />
+      </StoreContext.Provider>
     );
-    const buttons = queryAllByProps({ onSelect: handleSelect });
-    expect(buttons).toHaveLength(4); // the root component counts as 1
-    fireEvent(buttons[0], 'select', 'foo');
+    expect(queryAllByProps({ value: 'foo' })).toHaveLength(1);
+    expect(queryAllByProps({ value: 'bar' })).toHaveLength(1);
+    expect(queryAllByProps({ value: 'tacos' })).toHaveLength(1);
+    fireEvent(queryAllByProps({ value: 'bar' })[0], 'select', 'foo');
     expect(handleSelect).toHaveBeenCalledWith('foo');
   });
 });
diff --git a/src/app/src/components/__tests__/BuildInfo.test.tsx b/src/app/src/components/__tests__/BuildInfo.test.tsx
index 157cf79..4d0b35c 100644
--- a/src/app/src/components/__tests__/BuildInfo.test.tsx
+++ b/src/app/src/components/__tests__/BuildInfo.test.tsx
@@ -1,18 +1,26 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
+import * as Actions from '../../store/actions';
 import Build from '@build-tracker/build';
 import BuildInfo from '../BuildInfo';
+import Comparator from '@build-tracker/comparator';
+import mockStore from '../../store/mock';
 import React from 'react';
+import { StoreContext } from 'redux-react-hook';
 import { fireEvent, render } from 'react-native-testing-library';
 
 const build = new Build({ revision: '1234565', parentRevision: 'abcdef', timestamp: 123 }, []);
 
 describe('BuildInfo', () => {
   test('can be closed', () => {
-    const handleClose = jest.fn();
-    const { getByProps } = render(<BuildInfo build={build} onClose={handleClose} />);
+    const focusRevisionSpy = jest.spyOn(Actions, 'setFocusedRevision');
+    const { getByProps } = render(
+      <StoreContext.Provider value={mockStore({ comparator: new Comparator({ builds: [build] }) })}>
+        <BuildInfo focusedRevision="1234565" />
+      </StoreContext.Provider>
+    );
     fireEvent.press(getByProps({ title: 'Close' }));
-    expect(handleClose).toHaveBeenCalledWith('1234565');
+    expect(focusRevisionSpy).toHaveBeenCalledWith(undefined);
   });
 });
diff --git a/src/app/src/screens/__tests__/Main.test.tsx b/src/app/src/screens/__tests__/Main.test.tsx
index ffe6b1b..3ddff37 100644
--- a/src/app/src/screens/__tests__/Main.test.tsx
+++ b/src/app/src/screens/__tests__/Main.test.tsx
@@ -1,21 +1,19 @@
 /**
  * Copyright (c) 2019 Paul Armstrong
  */
+import * as Actions from '../../store/actions';
 import * as CrossFetch from 'cross-fetch';
-import AppBar from '../../components/AppBar';
+import Build from '@build-tracker/build';
 import buildA from '@build-tracker/fixtures/builds/22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04.json';
 import buildB from '@build-tracker/fixtures/builds/01141f29743fb2bdd7e176cf919fc964025cea5a.json';
 import buildC from '@build-tracker/fixtures/builds/243024909db66ac3c3e48d2ffe4015f049609834.json';
-import { Clipboard } from 'react-native';
-import ColorScale from '../../modules/ColorScale';
-import ColorScalePicker from '../../components/ColorScalePicker';
+import Comparator from '@build-tracker/comparator';
 import Comparison from '../../views/Comparison';
-import Drawer from '../../components/Drawer';
-import Graph from '../../components/Graph';
 import Main from '../Main';
+import mockStore from '../../store/mock';
 import React from 'react';
-import SizeKeyPicker from '../../components/SizeKeyPicker';
-import { fireEvent, flushMicrotasksQueue, render } from 'react-native-testing-library';
+import { StoreContext } from 'redux-react-hook';
+import { flushMicrotasksQueue, render } from 'react-native-testing-library';
 
 // React.memo components are not findable by type
 jest.mock('../../components/ColorScalePicker', () => {
@@ -59,8 +57,9 @@ jest.mock('cross-fetch', () => {
 const url = 'https://build-tracker.local';
 
 describe('Main', () => {
+  let fetchSpy;
   beforeEach(() => {
-    jest.spyOn(CrossFetch, 'fetch').mockImplementation(() =>
+    fetchSpy = jest.spyOn(CrossFetch, 'fetch').mockImplementation(() =>
       // @ts-ignore
       Promise.resolve({
         json: () => [buildA, buildB, buildC]
@@ -68,234 +67,49 @@ describe('Main', () => {
     );
   });
 
-  describe('drawer', () => {
-    test('shows the drawer when AppBar pressNavigationIcon hit', () => {
-      const showSpy = jest.spyOn(Drawer.prototype, 'show');
-      const { getByType } = render(<Main url={url} />);
-      fireEvent(getByType(AppBar), 'pressNavigationIcon');
-      expect(showSpy).toHaveBeenCalled();
-    });
-  });
-
-  describe('color scale', () => {
-    test('sets color scale context when scale is selected', async () => {
-      const { getByType } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      fireEvent(getByType(ColorScalePicker), 'select', ColorScale.Magma);
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      expect(getByType(ColorScalePicker).props.activeColorScale).toBe(ColorScale.Magma);
-      expect(getByType(Comparison).props.colorScale).toBe(ColorScale.Magma);
-      expect(getByType(Graph).props.colorScale).toBe(ColorScale.Magma);
+  describe('startup', () => {
+    test('fetches builds and updates', async () => {
+      const setBuildsSpy = jest.spyOn(Actions, 'setBuilds');
+      const component = (
+        <StoreContext.Provider
+          value={mockStore({
+            builds: [],
+            comparedRevisions: [],
+            comparator: new Comparator({ builds: [] }),
+            snacks: [],
+            url
+          })}
+        >
+          <Main />
+        </StoreContext.Provider>
+      );
+      const { update } = render(component);
+      update(component);
+      await flushMicrotasksQueue();
+      expect(fetchSpy).toHaveBeenCalledWith(`${url}/api/builds`);
+      expect(setBuildsSpy).toHaveBeenCalledWith(
+        expect.arrayContaining([expect.any(Build), expect.any(Build), expect.any(Build)])
+      );
     });
   });
 
-  describe('artifacts', () => {
-    test('can disable a artifacts', async () => {
-      const { getByType } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      fireEvent(getByType(Comparison), 'disableArtifacts', ['main']);
-      expect(getByType(Comparison).props.activeArtifacts).toMatchObject({
-        main: false,
-        vendor: true,
-        shared: true
-      });
-      expect(getByType(Graph).props.activeArtifacts).toMatchObject({ main: false, vendor: true, shared: true });
-    });
-
-    test('can enable a artifacts', async () => {
-      const { getByType } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      fireEvent(getByType(Comparison), 'disableArtifacts', ['main', 'vendor', 'shared']);
-      fireEvent(getByType(Comparison), 'enableArtifacts', ['main']);
-      expect(getByType(Comparison).props.activeArtifacts).toMatchObject({
-        main: true,
-        vendor: false,
-        shared: false
+  describe('comparison view', () => {
+    test('shows when there are compared revisions', async () => {
+      const store = mockStore({
+        builds: [new Build(buildA.meta, buildA.artifacts)],
+        comparedRevisions: [buildA.meta.revision],
+        comparator: new Comparator({ builds: [new Build(buildA.meta, buildA.artifacts)] }),
+        snacks: [],
+        url
       });
-      expect(getByType(Graph).props.activeArtifacts).toMatchObject({ main: true, vendor: false, shared: false });
-    });
-
-    test('can focus artifacts', async () => {
-      const { getByType } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      fireEvent(getByType(Comparison), 'focusArtifacts', ['main', 'vendor']);
-      expect(getByType(Comparison).props.activeArtifacts).toMatchObject({
-        main: true,
-        vendor: true,
-        shared: false
-      });
-      expect(getByType(Graph).props.activeArtifacts).toMatchObject({ main: true, vendor: true, shared: false });
-    });
-
-    test('can toggle visibility of disabled artifacts', async () => {
-      const { getByType, queryAllByProps } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      expect(queryAllByProps({ disabledArtifactsVisible: true })).toHaveLength(2);
-      fireEvent(getByType(Drawer), 'toggleDisabledArtifacts', false);
-
-      expect(queryAllByProps({ disabledArtifactsVisible: false })).toHaveLength(2);
-    });
-  });
-
-  describe('on select size key', () => {
-    test('passes the new size key to graph', async () => {
-      const { getByType, queryAllByProps } = render(<Main url={url} />);
-      fireEvent(getByType(SizeKeyPicker), 'select', 'stat');
-      expect(queryAllByProps({ sizeKey: 'stat' })).toHaveLength(2);
-      expect(queryAllByProps({ sizeKey: 'gzip' })).toHaveLength(0);
-    });
-  });
-
-  describe('hovering artifacts', () => {
-    test('updates hovered artifacts', async () => {
-      const { getByType } = render(<Main url={url} />);
-      expect(getByType(Graph).props.hoveredArtifacts).toEqual([]);
-      fireEvent(getByType(Graph), 'hoverArtifacts', ['main']);
-      expect(getByType(Graph).props.hoveredArtifacts).toEqual(['main']);
-    });
-
-    test('does not update hovered artifacts if equal', async () => {
-      const { getByType } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'hoverArtifacts', ['main']);
-      const { hoveredArtifacts } = getByType(Graph).props;
-      fireEvent(getByType(Graph), 'hoverArtifacts', ['main']);
-      expect(getByType(Graph).props.hoveredArtifacts).toBe(hoveredArtifacts);
-    });
-  });
-
-  describe('on select revision', () => {
-    test('updates the comparison table', async () => {
-      const { getByType } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      fireEvent(getByType(Graph), 'selectRevision', '243024909db66ac3c3e48d2ffe4015f049609834');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      expect(getByType(Comparison).props.comparator.builds.map(b => b.getMetaValue('revision'))).toEqual(
-        expect.arrayContaining(['243024909db66ac3c3e48d2ffe4015f049609834', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04'])
+      const component = (
+        <StoreContext.Provider value={store}>
+          <Main />
+        </StoreContext.Provider>
       );
-    });
-  });
-
-  describe('focused revisions', () => {
-    test('focusing a revision shows the build info', async () => {
-      const { getByType, queryAllByProps } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      fireEvent(getByType(Comparison), 'focusRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-
-      expect(queryAllByProps({ focusedRevision: '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04' })).toHaveLength(1);
-    });
-
-    test('removing focused revision hides the build info', async () => {
-      const { getByType, queryAllByProps } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      fireEvent(getByType(Graph), 'selectRevision', '243024909db66ac3c3e48d2ffe4015f049609834');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      fireEvent(getByType(Comparison), 'focusRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      fireEvent(getByType(Comparison), 'removeRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-
-      expect(queryAllByProps({ focusedRevision: null })).toHaveLength(1);
-    });
-
-    test('closing the build info removes the component', async () => {
-      const { getByType, queryAllByProps } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      fireEvent(getByType(Comparison), 'focusRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      fireEvent(getByType(Comparison), 'unfocusRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-
-      expect(queryAllByProps({ focusedRevision: null })).toHaveLength(1);
-    });
-  });
-
-  describe('overflow items', () => {
-    let clipboardSpy;
-    beforeEach(() => {
-      clipboardSpy = jest.spyOn(Clipboard, 'setString').mockImplementation(() => {});
-    });
-
-    test('can clear selected revisions', async () => {
-      const { getByType, getByProps, queryAllByType } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '243024909db66ac3c3e48d2ffe4015f049609834');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-
-      fireEvent.press(getByProps({ title: 'More actions' }));
-      fireEvent.press(getByProps({ label: 'Clear selected revisions' }));
-
-      expect(queryAllByType(Comparison)).toHaveLength(0);
-    });
-
-    test('can copy as markdown', async () => {
-      const { getByType, getByProps } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-      fireEvent(getByType(Graph), 'selectRevision', '243024909db66ac3c3e48d2ffe4015f049609834');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-
-      fireEvent.press(getByProps({ title: 'More actions' }));
-      fireEvent.press(getByProps({ label: 'Copy as markdown' }));
-      const comparator = getByType(Comparison).props.comparator;
-
-      expect(clipboardSpy).toHaveBeenCalledWith(comparator.toMarkdown());
-    });
-
-    test('shows a message when copied as markdown', async () => {
-      const { getByProps, getByType, queryAllByProps, queryAllByText } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-
-      fireEvent.press(getByProps({ title: 'More actions' }));
-      fireEvent.press(getByProps({ label: 'Copy as markdown' }));
-
-      expect(queryAllByProps({ accessibilityRole: 'alert' })).toHaveLength(1);
-      expect(queryAllByText('Copied table as markdown')).toHaveLength(1);
-    });
-
-    test('can copy as csv', async () => {
-      const { getByProps, getByType } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '243024909db66ac3c3e48d2ffe4015f049609834');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-
-      fireEvent.press(getByProps({ title: 'More actions' }));
-      fireEvent.press(getByProps({ label: 'Copy as CSV' }));
-
-      const comparator = getByType(Comparison).props.comparator;
-
-      expect(clipboardSpy).toHaveBeenCalledWith(comparator.toCsv());
-    });
-
-    test('shows a message when copied as csv', async () => {
-      const { getByProps, getByType, queryAllByProps, queryAllByText } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-
-      fireEvent.press(getByProps({ title: 'More actions' }));
-      fireEvent.press(getByProps({ label: 'Copy as CSV' }));
-
-      expect(queryAllByProps({ accessibilityRole: 'alert' })).toHaveLength(1);
-      expect(queryAllByText('Copied table as CSV')).toHaveLength(1);
-    });
-  });
-
-  describe('messages', () => {
-    test('removes messages after some time ', async () => {
-      jest.spyOn(Clipboard, 'setString').mockImplementation(() => {});
-      const { getByProps, getByType, queryAllByProps, update } = render(<Main url={url} />);
-      fireEvent(getByType(Graph), 'selectRevision', '22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04');
-      await flushMicrotasksQueue(); // ensure dynamic imports are loaded
-
-      fireEvent.press(getByProps({ title: 'More actions' }));
-      fireEvent.press(getByProps({ label: 'Copy as CSV' }));
-
-      expect(queryAllByProps({ accessibilityRole: 'alert' })).toHaveLength(1);
-      update(<Main url={url} />);
-      jest.runAllTimers();
-      expect(queryAllByProps({ accessibilityRole: 'alert' })).toHaveLength(0);
+      const { queryAllByType } = render(component);
+      await flushMicrotasksQueue();
+      expect(queryAllByType(Comparison)).toHaveLength(1);
     });
   });
 });
diff --git a/src/app/src/store/__tests__/reducer.test.ts b/src/app/src/store/__tests__/reducer.test.ts
new file mode 100644
index 0000000..30e8c07
--- /dev/null
+++ b/src/app/src/store/__tests__/reducer.test.ts
@@ -0,0 +1,179 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as Actions from '../actions';
+import Build from '@build-tracker/build';
+import ColorScale from '../../modules/ColorScale';
+import Comparator from '@build-tracker/comparator';
+import reducer from '../reducer';
+import { State } from '../types';
+
+const initialState: State = Object.freeze({
+  activeArtifacts: {},
+  activeComparator: null,
+  artifactConfig: {},
+  builds: [],
+  colorScale: Object.keys(ColorScale)[0],
+  comparator: new Comparator({ builds: [] }),
+  comparedRevisions: [],
+  disabledArtifactsVisible: true,
+  hoveredArtifacts: [],
+  snacks: [],
+  sizeKey: '',
+  url: 'https://build-tracker.local'
+});
+
+const buildA = new Build({ revision: '123', parentRevision: '000', timestamp: Date.now() - 400 }, [
+  { name: 'tacos', hash: '123', sizes: { stat: 123, gzip: 45 } }
+]);
+const buildB = new Build({ revision: 'abc', parentRevision: '123', timestamp: Date.now() }, [
+  { name: 'tacos', hash: 'abc', sizes: { stat: 125, gzip: 47 } }
+]);
+
+describe('reducer', () => {
+  describe('active artifacts', () => {
+    test('updates the active state of artifacts', () => {
+      const state = reducer(
+        { ...initialState, activeArtifacts: { churros: false } },
+        Actions.setArtifactActive(['tacos', 'burritos'], true)
+      );
+      expect(state.activeArtifacts).toEqual({ churros: false, tacos: true, burritos: true });
+    });
+  });
+
+  describe('set builds', () => {
+    test('stores builds', () => {
+      const state = reducer(initialState, Actions.setBuilds([buildA, buildB]));
+      expect(state.builds).toEqual([buildA, buildB]);
+    });
+
+    test('sets a new comparator with the given builds', () => {
+      const state = reducer(initialState, Actions.setBuilds([buildA, buildB]));
+      expect(state.comparator).toBeInstanceOf(Comparator);
+      expect(state.comparator.artifactNames).toEqual(['tacos']);
+    });
+
+    test('activates all artifacts', () => {
+      const state = reducer(initialState, Actions.setBuilds([buildA, buildB]));
+      expect(state.activeArtifacts).toEqual({ tacos: true });
+    });
+  });
+
+  describe('set color scale', () => {
+    test('sets the color scale', () => {
+      const state = reducer(initialState, Actions.setColorScale('Cool'));
+      expect(state.colorScale).toEqual('Cool');
+    });
+  });
+
+  describe('compared revisions', () => {
+    test('adding updates the active comparator', () => {
+      const state = reducer({ ...initialState, builds: [buildA, buildB] }, Actions.addComparedRevision('123'));
+      expect(state.comparedRevisions).toEqual(['123']);
+      expect(state.activeComparator.builds.map(b => b.getMetaValue('revision'))).toEqual(['123']);
+    });
+
+    test('removing updates the active comparator', () => {
+      const state = reducer(
+        { ...initialState, activeComparator: new Comparator({ builds: [buildA] }), builds: [buildA, buildB] },
+        Actions.removeComparedRevision('123')
+      );
+      expect(state.comparedRevisions).toEqual([]);
+      expect(state.activeComparator.builds).toEqual([]);
+    });
+
+    test('removing unfocuses the build if it is focused', () => {
+      const state = reducer(
+        {
+          ...initialState,
+          activeComparator: new Comparator({ builds: [buildA] }),
+          builds: [buildA, buildB],
+          comparedRevisions: ['123'],
+          focusedRevision: '123'
+        },
+        Actions.removeComparedRevision('123')
+      );
+      expect(state.focusedRevision).toBeUndefined();
+    });
+
+    test('clearing clears all', () => {
+      const state = reducer(
+        {
+          ...initialState,
+          activeComparator: new Comparator({ builds: [buildA, buildB] }),
+          builds: [buildA, buildB],
+          comparedRevisions: ['123', 'abc'],
+          focusedRevision: '123'
+        },
+        Actions.clearComparedRevisions()
+      );
+      expect(state.focusedRevision).toBeUndefined();
+      expect(state.comparedRevisions).toEqual([]);
+      expect(state.activeComparator).toBeNull();
+    });
+  });
+
+  describe('set focused revision', () => {
+    test('sets the revision', () => {
+      const state = reducer(initialState, Actions.setFocusedRevision('123'));
+      expect(state.focusedRevision).toBe('123');
+    });
+
+    test('can clear', () => {
+      const state = reducer(initialState, Actions.setFocusedRevision());
+      expect(state.focusedRevision).toBeUndefined();
+    });
+  });
+
+  describe('disabled artifacts visibility', () => {
+    test('can be toggled', () => {
+      const state = reducer(
+        { ...initialState, disabledArtifactsVisible: true },
+        Actions.setDisabledArtifactsVisible(false)
+      );
+      expect(state.disabledArtifactsVisible).toBe(false);
+    });
+  });
+
+  describe('set size key', () => {
+    test('sets the size key', () => {
+      const state = reducer({ ...initialState, sizeKey: 'tacos' }, Actions.setSizeKey('burritos'));
+      expect(state.sizeKey).toBe('burritos');
+    });
+  });
+
+  describe('snacks', () => {
+    test('can be added', () => {
+      const state = reducer({ ...initialState, snacks: ['tacos'] }, Actions.addSnack('burritos'));
+      expect(state.snacks).toEqual(['tacos', 'burritos']);
+    });
+
+    test('can be removed', () => {
+      const state = reducer(
+        { ...initialState, snacks: ['tacos', 'churros', 'burritos'] },
+        Actions.removeSnack('churros')
+      );
+      expect(state.snacks).toEqual(['tacos', 'burritos']);
+    });
+  });
+
+  describe('hovered artifacts', () => {
+    test('can be set', () => {
+      const state = reducer(initialState, Actions.setHoveredArtifacts(['burritos', 'tacos']));
+      expect(state.hoveredArtifacts).toEqual(['burritos', 'tacos']);
+    });
+
+    test('does not modify state if new hovered are equal', () => {
+      const mockState = { ...initialState, hoveredArtifacts: ['tacos', 'burritos'] };
+      const state = reducer(mockState, Actions.setHoveredArtifacts(['burritos', 'tacos']));
+      expect(state).toBe(mockState);
+    });
+  });
+
+  describe('unknown actions', () => {
+    test('returns the state', () => {
+      // @ts-ignore
+      expect(reducer(initialState, {})).toEqual(initialState);
+    });
+  });
+});
diff --git a/src/app/src/views/__tests__/AppBar.test.tsx b/src/app/src/views/__tests__/AppBar.test.tsx
new file mode 100644
index 0000000..3328110
--- /dev/null
+++ b/src/app/src/views/__tests__/AppBar.test.tsx
@@ -0,0 +1,152 @@
+/**
+ * Copyright (c) 2019 Paul Armstrong
+ */
+import * as Actions from '../../store/actions';
+import { AppBar } from '../../components/AppBar';
+import AppBarView from '../AppBar';
+import Build from '@build-tracker/build';
+import buildA from '@build-tracker/fixtures/builds/22abb6f829a07ca96ff56deeadf4d0e8fc2dbb04.json';
+import buildB from '@build-tracker/fixtures/builds/01141f29743fb2bdd7e176cf919fc964025cea5a.json';
+import { Clipboard } from 'react-native';
+import Comparator from '@build-tracker/comparator';
+import Drawer from '../../components/Drawer';
+import mockStore from '../../store/mock';
+import React from 'react';
+import { StoreContext } from 'redux-react-hook';
+import { fireEvent, render } from 'react-native-testing-library';
+
+jest.mock('../../components/Drawer', () => {
+  const React = jest.requireActual('react');
+  interface Props {
+    children: React.ReactElement;
+  }
+  class Drawer extends React.Component<Props> {
+    public render(): React.ReactElement {
+      return <>{this.props.children}</>;
+    }
+    public show(): void {}
+  }
+  return Drawer;
+});
+
+const initialState = Object.freeze({
+  comparator: new Comparator({ builds: [] }),
+  comparedRevisions: [],
+  sizeKey: '',
+  url: 'https://build-tracker.local'
+});
+
+describe('AppBarView', () => {
+  let drawerRef;
+  beforeEach(() => {
+    drawerRef = React.createRef();
+    render(
+      // @ts-ignore
+      <Drawer ref={drawerRef}>
+        <div />
+      </Drawer>
+    );
+  });
+
+  describe('drawer', () => {
+    test('shows the drawer when AppBar pressNavigationIcon hit', () => {
+      const showSpy = jest.spyOn(Drawer.prototype, 'show');
+      const { getByType } = render(
+        <StoreContext.Provider value={mockStore(initialState)}>
+          <AppBarView drawerRef={drawerRef} />
+        </StoreContext.Provider>
+      );
+      fireEvent(getByType(AppBar), 'pressNavigationIcon');
+      expect(showSpy).toHaveBeenCalled();
+    });
+  });
+
+  describe('overflow items', () => {
+    let clipboardSpy;
+    let seededState;
+    beforeEach(() => {
+      const comparator = new Comparator({
+        builds: [new Build(buildA.meta, buildA.artifacts), new Build(buildB.meta, buildB.artifacts)]
+      });
+      seededState = Object.freeze({
+        ...initialState,
+        activeArtifacts: comparator.artifactNames.reduce((memo, name) => {
+          memo[name] = true;
+          return memo;
+        }, {}),
+        activeComparator: new Comparator({
+          builds: [new Build(buildA.meta, buildA.artifacts), new Build(buildB.meta, buildB.artifacts)]
+        }),
+        comparator,
+        comparedRevisions: [buildA.meta.revision, buildB.meta.revision]
+      });
+
+      clipboardSpy = jest.spyOn(Clipboard, 'setString').mockImplementation(() => {});
+    });
+
+    test('can clear selected revisions', () => {
+      const clearComparedSpy = jest.spyOn(Actions, 'clearComparedRevisions');
+      const { getByProps } = render(
+        <StoreContext.Provider value={mockStore(seededState)}>
+          <AppBarView drawerRef={drawerRef} />
+        </StoreContext.Provider>
+      );
+
+      fireEvent.press(getByProps({ title: 'More actions' }));
+      fireEvent.press(getByProps({ label: 'Clear selected revisions' }));
+      expect(clearComparedSpy).toHaveBeenCalled();
+    });
+
+    test('can copy as markdown', () => {
+      const { getByProps } = render(
+        <StoreContext.Provider value={mockStore(seededState)}>
+          <AppBarView drawerRef={drawerRef} />
+        </StoreContext.Provider>
+      );
+
+      fireEvent.press(getByProps({ title: 'More actions' }));
+      fireEvent.press(getByProps({ label: 'Copy as markdown' }));
+
+      expect(clipboardSpy).toHaveBeenCalledWith(seededState.comparator.toMarkdown());
+    });
+
+    test('shows a message when copied as markdown', () => {
+      const addSnackSpy = jest.spyOn(Actions, 'addSnack');
+      const { getByProps } = render(
+        <StoreContext.Provider value={mockStore(seededState)}>
+          <AppBarView drawerRef={drawerRef} />
+        </StoreContext.Provider>
+      );
+
+      fireEvent.press(getByProps({ title: 'More actions' }));
+      fireEvent.press(getByProps({ label: 'Copy as markdown' }));
+      expect(addSnackSpy).toHaveBeenCalledWith('Copied table as markdown');
+    });
+
+    test('can copy as csv', () => {
+      const { getByProps } = render(
+        <StoreContext.Provider value={mockStore(seededState)}>
+          <AppBarView drawerRef={drawerRef} />
+        </StoreContext.Provider>
+      );
+
+      fireEvent.press(getByProps({ title: 'More actions' }));
+      fireEvent.press(getByProps({ label: 'Copy as CSV' }));
+
+      expect(clipboardSpy).toHaveBeenCalledWith(seededState.comparator.toCsv());
+    });
+
+    test('shows a message when copied as csv', () => {
+      const addSnackSpy = jest.spyOn(Actions, 'addSnack');
+      const { getByProps } = render(
+        <StoreContext.Provider value={mockStore(seededState)}>
+          <AppBarView drawerRef={drawerRef} />
+        </StoreContext.Provider>
+      );
+
+      fireEvent.press(getByProps({ title: 'More actions' }));
+      fireEvent.press(getByProps({ label: 'Copy as CSV' }));
+      expect(addSnackSpy).toHaveBeenCalledWith('Copied table as CSV');
+    });
+  });
+});