Update documents/changes to use redux (#1134)

* Split components to separate files

* Use redux

* Update tests
diff --git a/app/addons/documents/__tests__/changes-reducers.test.js b/app/addons/documents/__tests__/changes-reducers.test.js
new file mode 100644
index 0000000..9b494fc
--- /dev/null
+++ b/app/addons/documents/__tests__/changes-reducers.test.js
@@ -0,0 +1,145 @@
+
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import FauxtonAPI from '../../../core/api';
+import utils from '../../../../test/mocha/testUtils';
+import ActionTypes from '../changes/actiontypes';
+import reducer from '../changes/reducers';
+
+FauxtonAPI.router = new FauxtonAPI.Router([]);
+
+const assert = utils.assert;
+
+describe('Changes Reducer', () => {
+
+  const changesList = [
+    { id: 'doc_1', seq: 4, deleted: false, changes: { code: 'here' }, isNew: false },
+    { id: 'doc_2', seq: 1, deleted: false, changes: { code: 'here' }, isNew: false },
+    { id: 'doc_3', seq: 6, deleted: true, changes: { code: 'here' }, isNew: false },
+    { id: 'doc_4', seq: 7, deleted: false, changes: { code: 'here' }, isNew: false },
+    { id: 'doc_5', seq: 1, deleted: true, changes: { code: 'here' }, isNew: false }
+  ];
+
+  it('adds new filter to state', () => {
+    const filter = 'My filter';
+    const action = {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter
+    };
+    const newState = reducer(undefined, action);
+
+    assert.ok(newState.filters.length === 1);
+    assert.ok(newState.filters[0] === filter);
+  });
+
+  it('removes filter from state', () => {
+    const filter1 = 'My filter 1';
+    const filter2 = 'My filter 2';
+    let newState = reducer(undefined, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: filter1
+    });
+    newState = reducer(newState, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: filter2
+    });
+    newState = reducer(newState, {
+      type: ActionTypes.REMOVE_CHANGES_FILTER_ITEM,
+      filter: filter1
+    });
+
+    assert.ok(newState.filters.length === 1);
+    assert.ok(newState.filters[0] === filter2);
+  });
+
+  it('number of items is capped by maxChangesListed', () => {
+
+    // to keep the test speedy, we override the default max value
+    const maxChanges = 10;
+    const changes = [];
+    for (let i = 0; i < maxChanges + 10; i++) {
+      changes.push({ id: 'doc_' + i, seq: 1, changes: {}});
+    }
+    let state = reducer(undefined, {type: 'DO_NOTHING'});
+    state.maxChangesListed = maxChanges;
+
+    const seqNum = 123;
+    state = reducer(state, {
+      type: ActionTypes.UPDATE_CHANGES,
+      seqNum,
+      changes
+    });
+    assert.equal(state.changes.length, changes.length);
+    assert.equal(state.filteredChanges.length, maxChanges);
+  });
+
+  it('tracks last sequence number', () => {
+    let state = reducer(undefined, {type: 'DO_NOTHING'});
+    assert.equal(state.lastSequenceNum, null);
+
+    const seqNum = 123;
+    state = reducer(state, {
+      type: ActionTypes.UPDATE_CHANGES,
+      seqNum,
+      changes: []
+    });
+
+    // confirm it's been stored
+    assert.equal(state.lastSequenceNum, seqNum);
+  });
+
+  it('"true" filter should apply to change deleted status', () => {
+    let state = reducer(undefined, {
+      type: ActionTypes.UPDATE_CHANGES,
+      seqNum: 123,
+      changes: changesList
+    });
+
+    // add a filter
+    state = reducer(state, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: 'true'
+    });
+
+    // confirm only the two deleted items are part of filtered results
+    assert.equal(state.filteredChanges.length, 2);
+    state.filteredChanges.forEach(el => {
+      assert.equal(el.deleted, true);
+    });
+  });
+
+  // confirms that if there are multiple filters, ALL are applied to return the subset of results that match
+  // all filters
+  it('multiple filters should all be applied to results', () => {
+    let state = reducer(undefined, {
+      type: ActionTypes.UPDATE_CHANGES,
+      seqNum: 123,
+      changes: changesList
+    });
+
+    // add the filters
+    state = reducer(state, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: 'true'
+    });
+    state = reducer(state, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: '1'
+    });
+
+    // confirm only doc_5 matches both filters
+    assert.equal(state.filteredChanges.length, 1);
+    assert.equal(state.filteredChanges[0].id, 'doc_5');
+  });
+
+});
diff --git a/app/addons/documents/__tests__/changes-stores.test.js b/app/addons/documents/__tests__/changes-stores.test.js
deleted file mode 100644
index 7336af0..0000000
--- a/app/addons/documents/__tests__/changes-stores.test.js
+++ /dev/null
@@ -1,90 +0,0 @@
-
-// Licensed under the Apache License, Version 2.0 (the "License"); you may not
-// use this file except in compliance with the License. You may obtain a copy of
-// the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-// License for the specific language governing permissions and limitations under
-// the License.
-
-import FauxtonAPI from "../../../core/api";
-import Stores from "../changes/stores";
-import utils from "../../../../test/mocha/testUtils";
-FauxtonAPI.router = new FauxtonAPI.Router([]);
-
-const assert = utils.assert;
-
-describe('ChangesStore', () => {
-
-  afterEach(() => {
-    Stores.changesStore.reset();
-  });
-
-  it('addFilter() adds item in store', () => {
-    const filter = 'My filter';
-    Stores.changesStore.addFilter(filter);
-    const filters = Stores.changesStore.getFilters();
-    assert.ok(filters.length === 1);
-    assert.ok(filters[0] === filter);
-  });
-
-  it('removeFilter() removes item from store', () => {
-    const filter1 = 'My filter 1';
-    const filter2 = 'My filter 2';
-    Stores.changesStore.addFilter(filter1);
-    Stores.changesStore.addFilter(filter2);
-    Stores.changesStore.removeFilter(filter1);
-
-    const filters = Stores.changesStore.getFilters();
-    assert.ok(filters.length === 1);
-    assert.ok(filters[0] === filter2);
-  });
-
-  it('hasFilter() finds item in store', () => {
-    const filter = 'My filter';
-    Stores.changesStore.addFilter(filter);
-    assert.ok(Stores.changesStore.hasFilter(filter) === true);
-  });
-
-  it('getDatabaseName() returns database name', () => {
-    const dbName = 'hoopoes';
-    Stores.changesStore.initChanges({ databaseName: dbName });
-    assert.equal(Stores.changesStore.getDatabaseName(), dbName);
-
-    Stores.changesStore.reset();
-    assert.equal(Stores.changesStore.getDatabaseName(), '');
-  });
-
-  it("getChanges() should return a subset if there are a lot of changes", () => {
-
-    // to keep the test speedy, we override the default max value
-    const maxChanges = 10;
-    const changes = [];
-    _.times(maxChanges + 10, (i) => {
-      changes.push({ id: 'doc_' + i, seq: 1, changes: {}});
-    });
-    Stores.changesStore.initChanges({ databaseName: "test" });
-    Stores.changesStore.setMaxChanges(maxChanges);
-
-    const seqNum = 123;
-    Stores.changesStore.updateChanges(seqNum, changes);
-
-    const results = Stores.changesStore.getChanges();
-    assert.equal(maxChanges, results.length);
-  });
-
-  it("tracks last sequence number", () => {
-    assert.equal(null, Stores.changesStore.getLastSeqNum());
-
-    const seqNum = 123;
-    Stores.changesStore.updateChanges(seqNum, []);
-
-    // confirm it's been stored
-    assert.equal(seqNum, Stores.changesStore.getLastSeqNum());
-  });
-
-});
diff --git a/app/addons/documents/__tests__/changes.test.js b/app/addons/documents/__tests__/changes.test.js
index 0745458..833c8c1 100644
--- a/app/addons/documents/__tests__/changes.test.js
+++ b/app/addons/documents/__tests__/changes.test.js
@@ -13,9 +13,9 @@
 
 import React from "react";
 import ReactDOM from "react-dom";
-import Changes from "../changes/components";
-import Stores from "../changes/stores";
-import Actions from "../changes/actions";
+import ChangeRow from '../changes/components/ChangeRow';
+import ChangesScreen from '../changes/components/ChangesScreen';
+import ChangesTabContent from "../changes/components/ChangesTabContent";
 import {mount} from 'enzyme';
 import utils from "../../../../test/mocha/testUtils";
 import sinon from "sinon";
@@ -25,86 +25,78 @@
 
 
 describe('ChangesTabContent', () => {
-  let el;
+  const defaultProps = {
+    filters: [],
+    addFilter: () => {},
+    removeFilter: () => {}
+  };
 
-  beforeEach(() => {
-    el = mount(<Changes.ChangesTabContent />);
-  });
-
-  afterEach(() => {
-    Stores.changesStore.reset();
-  });
-
-  it('should add filter markup', () => {
-    const submitBtn = el.find('[type="submit"]'),
-          addItemField = el.find('.js-changes-filter-field');
-
-    addItemField.simulate('change', {target: {value: 'I wandered lonely as a filter'}});
-    submitBtn.simulate('submit');
-
-    addItemField.simulate('change', {target: {value: 'A second filter'}});
-    submitBtn.simulate('submit');
+  it('should add filter badges', () => {
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      filters={['I wandered lonely as a filter', 'A second filter']}
+    />);
 
     assert.equal(2, el.find('.remove-filter').length);
   });
 
   it('should call addFilter action on click', () => {
-    const submitBtn = el.find('[type="submit"]'),
-          addItemField = el.find('.js-changes-filter-field');
-
-    const spy = sinon.spy(Actions, 'addFilter');
-
-    addItemField.simulate('change', {target: {value: 'I wandered lonely as a filter'}});
-    submitBtn.simulate('submit');
-
-    assert.ok(spy.calledOnce);
-  });
-
-  it('should remove filter markup', () => {
+    const addFilterStub = sinon.stub();
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      addFilter={addFilterStub}
+    />);
     const submitBtn = el.find('[type="submit"]'),
           addItemField = el.find('.js-changes-filter-field');
 
     addItemField.simulate('change', {target: {value: 'I wandered lonely as a filter'}});
     submitBtn.simulate('submit');
 
-    addItemField.simulate('change', {target: {value: 'Flibble'}});
-    submitBtn.simulate('submit');
-
-    // clicks ALL 'remove' elements
-    el.find('.remove-filter').first().simulate('click');
-    el.find('.remove-filter').simulate('click');
-
-    assert.equal(0, el.find('.remove-filter').length);
+    assert.ok(addFilterStub.calledOnce);
   });
 
   it('should call removeFilter action on click', () => {
-    const submitBtn = el.find('[type="submit"]'),
-          addItemField = el.find('.js-changes-filter-field');
-
-    const spy = sinon.spy(Actions, 'removeFilter');
-
-    addItemField.simulate('change', {target: {value: 'Flibble'}});
-    submitBtn.simulate('submit');
+    const removeFilterStub = sinon.stub();
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      filters={['I wandered lonely as a filter']}
+      removeFilter={removeFilterStub}
+    />);
     el.find('.remove-filter').simulate('click');
 
-    assert.ok(spy.calledOnce);
+    assert.ok(removeFilterStub.calledOnce);
   });
 
   it('should not add empty filters', () => {
+    const addFilterStub = sinon.stub();
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      addFilter={addFilterStub}
+    />);
     const submitBtn = el.find('[type="submit"]'),
           addItemField = el.find('.js-changes-filter-field');
 
     addItemField.simulate('change', {target: {value: ''}});
     submitBtn.simulate('submit');
 
-    assert.equal(0, el.find('.remove-filter').length);
+    assert.ok(addFilterStub.notCalled);
   });
 
-  it('should not add tooltips by default', () => {
+  it('should not add badges by default', () => {
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+    />);
     assert.equal(0, el.find('.remove-filter').length);
   });
 
   it('should not add the same filter twice', () => {
+    const filters = [];
+    let callCount = 0;
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      addFilter={(f) => {filters.push(f); callCount++;}}
+      filters={filters}
+    />);
     const submitBtn = el.find('[type="submit"]'),
           addItemField = el.find('.js-changes-filter-field');
 
@@ -115,143 +107,52 @@
     addItemField.simulate('change', {target: {value: filter}});
     submitBtn.simulate('submit');
 
-    assert.equal(1, el.find('.remove-filter').length);
+    assert.equal(callCount, 1);
   });
 });
 
 
-describe('ChangesController', () => {
-  let  headerEl, changesEl;
-
-  const results = [
-    { id: 'doc_1', seq: 4, deleted: false, changes: { code: 'here' } },
-    { id: 'doc_2', seq: 1, deleted: false, changes: { code: 'here' } },
-    { id: 'doc_3', seq: 6, deleted: true, changes: { code: 'here' } },
-    { id: 'doc_4', seq: 7, deleted: false, changes: { code: 'here' } },
-    { id: 'doc_5', seq: 1, deleted: true, changes: { code: 'here' } }
+describe('ChangesScreen', () => {
+  const changesList = [
+    { id: 'doc_1', seq: 4, deleted: false, changes: { code: 'here' }, isNew: false },
+    { id: 'doc_2', seq: 1, deleted: false, changes: { code: 'here' }, isNew: false },
+    { id: 'doc_3', seq: 6, deleted: true, changes: { code: 'here' }, isNew: false },
+    { id: 'doc_4', seq: 7, deleted: false, changes: { code: 'here' }, isNew: false },
+    { id: 'doc_5', seq: 1, deleted: true, changes: { code: 'here' }, isNew: false }
   ];
 
-  const changesResponse = {
-    last_seq: 123,
-    'results': results
+  const defaultProps = {
+    changes: [],
+    loaded: true,
+    databaseName: 'my_db',
+    isShowingSubset: false,
+    loadChanges: () => {}
   };
 
-  beforeEach(() => {
-    Actions.initChanges({ databaseName: 'testDatabase' });
-    Actions.updateChanges(changesResponse);
-    headerEl  = mount(<Changes.ChangesTabContent />);
-    changesEl = mount(<Changes.ChangesController />);
-  });
-
-  afterEach(() => {
-    Stores.changesStore.reset();
-  });
-
-
   it('should list the right number of changes', () => {
-    changesEl.update();
-    assert.equal(results.length, changesEl.find('.change-box').length);
-  });
+    const changesEl = mount(<ChangesScreen
+      {...defaultProps}
+      changes={changesList}
+    />);
 
-
-  it('"false"/"true" filter strings should apply to change deleted status', () => {
-    // add a filter
-    const addItemField = headerEl.find('.js-changes-filter-field');
-    const submitBtn = headerEl.find('[type="submit"]');
-    addItemField.value = 'true';
-    addItemField.simulate('change', {target: {value: 'true'}});
-    submitBtn.simulate('submit');
-
-    // confirm only the two deleted items shows up and the IDs maps to the deleted rows
-    changesEl.update();
-    assert.equal(2, changesEl.find('.change-box').length);
-    assert.equal('doc_3', changesEl.find('.js-doc-id').first().text());
-    assert.equal('doc_5', changesEl.find('.js-doc-id').at(1).text());
-  });
-
-
-  it('confirms that a filter affects the actual search results', () => {
-    // add a filter
-    const addItemField = headerEl.find('.js-changes-filter-field');
-    const submitBtn = headerEl.find('[type="submit"]');
-    addItemField.simulate('change', {target: {value: '6'}});
-    submitBtn.simulate('submit');
-
-    // confirm only one item shows up and the ID maps to what we'd expect
-    changesEl.update();
-    assert.equal(1, changesEl.find('.change-box').length);
-    assert.equal('doc_3', changesEl.find('.js-doc-id').first().text());
-  });
-
-
-  // confirms that if there are multiple filters, ALL are applied to return the subset of results that match
-  // all filters
-  it('multiple filters should all be applied to results', () => {
-    // add the filters
-    const addItemField = headerEl.find('.js-changes-filter-field');
-    const submitBtn = headerEl.find('[type="submit"]');
-
-    // *** should match doc_1, doc_2 and doc_5
-    addItemField.simulate('change', {target: {value: '1'}});
-    submitBtn.simulate('submit');
-
-    // *** should match doc_3 and doc_5
-    addItemField.simulate('change', {target: {value: 'true'}});
-    submitBtn.simulate('submit');
-
-    // confirm only one item shows up and that it's doc_5
-    changesEl.update();
-    assert.equal(1, changesEl.find('.change-box').length);
-    assert.equal('doc_5', changesEl.find('.js-doc-id').first().text());
+    assert.equal(changesList.length, changesEl.find('.change-box').length);
   });
 
   it('shows a No Docs Found message if no docs', () => {
-    Stores.changesStore.reset();
-    Actions.updateChanges({ last_seq: 124, results: [] });
-    changesEl.update();
+    const changesEl = mount(<ChangesScreen
+      {...defaultProps}
+    />);
     assert.ok(/There\sare\sno\sdocument\schanges/.test(changesEl.html()));
   });
-});
-
-
-describe('ChangesController max results', () => {
-  let changesEl;
-  const maxChanges = 10;
-
-
-  beforeEach(() => {
-    const changes = [];
-    _.times(maxChanges + 10, (i) => {
-      changes.push({ id: 'doc_' + i, seq: 1, changes: { code: 'here' } });
-    });
-
-    const response = {
-      last_seq: 1,
-      results: changes
-    };
-
-    Actions.initChanges({ databaseName: 'test' });
-
-    // to keep the test speedy, override the default value (1000)
-    Stores.changesStore.setMaxChanges(maxChanges);
-
-    Actions.updateChanges(response);
-    changesEl = mount(<Changes.ChangesController />);
-  });
-
-  afterEach(() => {
-    Stores.changesStore.reset();
-  });
-
-  it('should truncate the number of results with very large # of changes', () => {
-    // check there's no more than maxChanges results
-    assert.equal(maxChanges, changesEl.find('.change-box').length);
-  });
 
   it('should show a message if the results are truncated', () => {
+    const changesEl = mount(<ChangesScreen
+      {...defaultProps}
+      changes={changesList}
+      isShowingSubset={true}
+    />);
     assert.equal(1, changesEl.find('.changes-result-limit').length);
   });
-
 });
 
 
@@ -264,7 +165,7 @@
   };
 
   it('clicking the toggle-json button shows the code section', function () {
-    const changeRow = mount(<Changes.ChangeRow change={change} databaseName="testDatabase" />);
+    const changeRow = mount(<ChangeRow change={change} databaseName="testDatabase" />);
 
     // confirm it's hidden by default
     assert.equal(0, changeRow.find('.prettyprint').length);
@@ -276,13 +177,13 @@
 
   it('deleted docs should not be clickable', () => {
     change.deleted = true;
-    const changeRow = mount(<Changes.ChangeRow change={change} databaseName="testDatabase" />);
+    const changeRow = mount(<ChangeRow change={change} databaseName="testDatabase" />);
     assert.equal(0, changeRow.find('a.js-doc-link').length);
   });
 
   it('non-deleted docs should be clickable', () => {
     change.deleted = false;
-    const changeRow = mount(<Changes.ChangeRow change={change} databaseName="testDatabase" />);
+    const changeRow = mount(<ChangeRow change={change} databaseName="testDatabase" />);
     assert.equal(1, changeRow.find('a.js-doc-link').length);
   });
 });
diff --git a/app/addons/documents/base.js b/app/addons/documents/base.js
index 090a450..c5f7826 100644
--- a/app/addons/documents/base.js
+++ b/app/addons/documents/base.js
@@ -20,6 +20,7 @@
 import sidebarReducers from "./sidebar/reducers";
 import partitionKeyReducers from "./partition-key/reducers";
 import revisionBrowserReducers from './rev-browser/reducers';
+import changesReducers from './changes/reducers';
 import "./assets/less/documents.less";
 
 FauxtonAPI.addReducers({
@@ -28,6 +29,7 @@
   sidebar: sidebarReducers,
   revisionBrowser: revisionBrowserReducers,
   partitionKey: partitionKeyReducers,
+  changes: changesReducers,
   designDocInfo: designDocInfoReducers
 });
 
diff --git a/app/addons/documents/changes/actions.js b/app/addons/documents/changes/actions.js
index 224bd12..f6f72d4 100644
--- a/app/addons/documents/changes/actions.js
+++ b/app/addons/documents/changes/actions.js
@@ -11,80 +11,74 @@
 // License for the specific language governing permissions and limitations under
 // the License.
 
-import app from "../../../app";
-import FauxtonAPI from "../../../core/api";
-import { get } from "../../../core/ajax";
-import ActionTypes from "./actiontypes";
-import Stores from "./stores";
-import Helpers from "../helpers";
+import app from '../../../app';
+import FauxtonAPI from '../../../core/api';
+import { get } from '../../../core/ajax';
+import ActionTypes from './actiontypes';
+import Helpers from '../helpers';
 
-const changesStore = Stores.changesStore;
 const pollingTimeout = 60000;
-var currentRequest;
+let currentRequest;
 
+const addFilter = (filter) => (dispatch) => {
+  dispatch({
+    type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+    filter: filter
+  });
+};
+
+const removeFilter = (filter) => (dispatch) => {
+  dispatch({
+    type: ActionTypes.REMOVE_CHANGES_FILTER_ITEM,
+    filter: filter
+  });
+};
+
+const loadChanges = (databaseName) => (dispatch) => {
+  currentRequest = null;
+  getLatestChanges(dispatch, databaseName);
+};
+
+const getLatestChanges = (dispatch, databaseName, lastSeqNum) => {
+  const params = {
+    limit: 100
+  };
+
+  // after the first request for the changes list has been made, switch to longpoll
+  if (currentRequest) {
+    params.since = lastSeqNum;
+    params.timeout = pollingTimeout;
+    params.feed = 'longpoll';
+  }
+
+  const query = app.utils.queryParams(params);
+  const db = app.utils.safeURLName(databaseName);
+  const endpoint = FauxtonAPI.urls('changes', 'server', db, '?' + query);
+  get(endpoint).then((res) => {
+    if (res.error) {
+      throw new Error(res.reason || res.error);
+    }
+    updateChanges(res, dispatch);
+  }).catch((err) => {
+    FauxtonAPI.addNotification({
+      msg: 'Error loading list of changes. Reason: ' + err.message,
+      type: 'error',
+      clear: true
+    });
+  });
+};
+
+const updateChanges = (json, dispatch) => {
+  const latestSeqNum = Helpers.getSeqNum(json.last_seq);
+  dispatch({
+    type: ActionTypes.UPDATE_CHANGES,
+    changes: json.results,
+    seqNum: latestSeqNum
+  });
+};
 
 export default {
-  addFilter: function (filter) {
-    FauxtonAPI.dispatch({
-      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
-      filter: filter
-    });
-  },
-
-  removeFilter: function (filter) {
-    FauxtonAPI.dispatch({
-      type: ActionTypes.REMOVE_CHANGES_FILTER_ITEM,
-      filter: filter
-    });
-  },
-
-  initChanges: function (options) {
-    FauxtonAPI.dispatch({
-      type: ActionTypes.INIT_CHANGES,
-      options: options
-    });
-    currentRequest = null;
-    this.getLatestChanges();
-  },
-
-  getLatestChanges: function () {
-    const params = {
-      limit: 100
-    };
-
-    // after the first request for the changes list has been made, switch to longpoll
-    if (currentRequest) {
-      params.since = changesStore.getLastSeqNum();
-      params.timeout = pollingTimeout;
-      params.feed = 'longpoll';
-    }
-
-    const query = app.utils.queryParams(params);
-    const db = app.utils.safeURLName(changesStore.getDatabaseName());
-    const endpoint = FauxtonAPI.urls('changes', 'server', db, '?' + query);
-    get(endpoint).then((res) => {
-      if (res.error) {
-        throw new Error(res.reason || res.error);
-      }
-      this.updateChanges(res);
-    }).catch((err) => {
-      FauxtonAPI.addNotification({
-        msg: 'Error loading list of changes. Reason: ' + err.message,
-        type: 'error',
-        clear: true
-      });
-    });
-  },
-
-  updateChanges: function (json) {
-    // only bother updating the list of changes if the seq num has changed
-    const latestSeqNum = Helpers.getSeqNum(json.last_seq);
-    if (latestSeqNum !== changesStore.getLastSeqNum()) {
-      FauxtonAPI.dispatch({
-        type: ActionTypes.UPDATE_CHANGES,
-        changes: json.results,
-        seqNum: latestSeqNum
-      });
-    }
-  }
+  addFilter,
+  removeFilter,
+  loadChanges
 };
diff --git a/app/addons/documents/changes/actiontypes.js b/app/addons/documents/changes/actiontypes.js
index bd2e412..6325683 100644
--- a/app/addons/documents/changes/actiontypes.js
+++ b/app/addons/documents/changes/actiontypes.js
@@ -11,7 +11,6 @@
 // the License.
 
 export default {
-  INIT_CHANGES: 'INIT_CHANGES',
   UPDATE_CHANGES: 'UPDATE_CHANGES',
   ADD_CHANGES_FILTER_ITEM: 'ADD_CHANGES_FILTER_ITEM',
   REMOVE_CHANGES_FILTER_ITEM: 'REMOVE_CHANGES_FILTER_ITEM',
diff --git a/app/addons/documents/changes/components.js b/app/addons/documents/changes/components.js
deleted file mode 100644
index 3c5d1c9..0000000
--- a/app/addons/documents/changes/components.js
+++ /dev/null
@@ -1,411 +0,0 @@
-import FauxtonAPI from "../../../core/api";
-
-// Licensed under the Apache License, Version 2.0 (the "License"); you may not
-// use this file except in compliance with the License. You may obtain a copy of
-// the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-// License for the specific language governing permissions and limitations under
-// the License.
-
-import PropTypes from 'prop-types';
-
-import React from "react";
-import ReactDOM from "react-dom";
-import Actions from "./actions";
-import Stores from "./stores";
-import Components from "../../fauxton/components";
-import ReactComponents from "../../components/react-components";
-import {TransitionMotion, spring, presets} from 'react-motion';
-import "../../../../assets/js/plugins/prettify";
-import uuid from 'uuid';
-
-const store = Stores.changesStore;
-const BadgeList = ReactComponents.BadgeList;
-const {Copy} = ReactComponents;
-
-class ChangesController extends React.Component {
-  constructor (props) {
-    super(props);
-    this.state = this.getStoreState();
-  }
-
-  getStoreState () {
-    return {
-      changes: store.getChanges(),
-      loaded: store.isLoaded(),
-      databaseName: store.getDatabaseName(),
-      isShowingSubset: store.isShowingSubset()
-    };
-  }
-
-  onChange () {
-    this.setState(this.getStoreState());
-  }
-
-  componentDidMount () {
-    store.on('change', this.onChange, this);
-  }
-
-  componentWillUnmount () {
-    store.off('change', this.onChange);
-  }
-
-  showingSubsetMsg () {
-    const { isShowingSubset, changes } = this.state;
-    let msg = '';
-    if (isShowingSubset) {
-      let numChanges = changes.length;
-      msg = <p className="changes-result-limit">Limiting results to latest <b>{numChanges}</b> changes.</p>;
-    }
-    return msg;
-  }
-
-  getRows () {
-    const { changes, loaded, databaseName } = this.state;
-    if (!changes.length && loaded) {
-      return (
-        <p className="no-doc-changes">
-          There are no document changes to display.
-        </p>
-      );
-    }
-
-    return changes.map((change, i) => {
-      return <ChangeRow change={change} key={i} databaseName={databaseName} />;
-    });
-  }
-
-  render () {
-    return (
-      <div>
-        <div className="js-changes-view">
-          {this.showingSubsetMsg()}
-          {this.getRows()}
-        </div>
-      </div>
-    );
-  }
-}
-
-
-class ChangesTabContent extends React.Component {
-  constructor (props) {
-    super(props);
-    this.state = this.getStoreState();
-  }
-
-  getStoreState () {
-    return {
-      filters: store.getFilters()
-    };
-  }
-
-  onChange () {
-    this.setState(this.getStoreState());
-  }
-
-  componentDidMount () {
-    store.on('change', this.onChange, this);
-  }
-
-  componentWillUnmount () {
-    store.off('change', this.onChange);
-  }
-
-  addFilter (newFilter) {
-    if (_.isEmpty(newFilter)) {
-      return;
-    }
-    Actions.addFilter(newFilter);
-  }
-
-  hasFilter (filter) {
-    return store.hasFilter(filter);
-  }
-
-  render () {
-    return (
-      <div className="changes-header">
-        <AddFilterForm filter={(label) => Actions.removeFilter(label)} addFilter={this.addFilter}
-          hasFilter={this.hasFilter} />
-        <BadgeList elements={this.state.filters} removeBadge={(label) => Actions.removeFilter(label)} />
-      </div>
-    );
-  }
-}
-
-
-class AddFilterForm extends React.Component {
-  constructor (props) {
-    super(props);
-    this.state = {
-      filter: '',
-      error: false
-    };
-    this.submitForm = this.submitForm.bind(this);
-  }
-
-  submitForm (e) {
-    e.preventDefault();
-    e.stopPropagation();
-
-    if (this.props.hasFilter(this.state.filter)) {
-      this.setState({ error: true });
-
-      // Yuck. This removes the class after the effect has completed so it can occur again. The
-      // other option is to use jQuery to add the flash. This seemed slightly less crumby
-      let component = this;
-      setTimeout(function () {
-        component.setState({ error: false });
-      }, 1000);
-    } else {
-      this.props.addFilter(this.state.filter);
-      this.setState({ filter: '', error: false });
-    }
-  }
-
-  componentDidMount () {
-    this.focusFilterField();
-  }
-
-  componentDidUpdate () {
-    this.focusFilterField();
-  }
-
-  focusFilterField () {
-    this.addItem.focus();
-  }
-
-  inputClassNames () {
-    let className = 'js-changes-filter-field';
-    if (this.state.error) {
-      className += ' errorHighlight';
-    }
-    return className;
-  }
-
-  render () {
-    return (
-      <form className="form-inline js-filter-form" onSubmit={this.submitForm}>
-        <fieldset>
-          <i className="fonticon-filter" />
-          <input
-            type="text"
-            ref={node => this.addItem = node}
-            className={this.inputClassNames()}
-            placeholder="Sequence or ID"
-            onChange={(e) => this.setState({ filter: e.target.value })}
-            value={this.state.filter} />
-          <button type="submit" className="btn btn-secondary">Filter</button>
-          <div className="help-block"></div>
-        </fieldset>
-      </form>
-    );
-  }
-}
-AddFilterForm.propTypes = {
-  addFilter: PropTypes.func.isRequired,
-  hasFilter: PropTypes.func.isRequired,
-  tooltips: PropTypes.string
-};
-AddFilterForm.defaultProps = {
-  tooltip: ''
-};
-
-class ChangeRow extends React.Component {
-  constructor (props) {
-    super(props);
-    this.state = {
-      codeVisible: false
-    };
-  }
-
-  toggleJSON (e) {
-    e.preventDefault();
-    this.setState({ codeVisible: !this.state.codeVisible });
-  }
-
-  getChangesCode () {
-    return (this.state.codeVisible) ? <Components.CodeFormat key="changesCodeSection" code={this.getChangeCode()} /> : null;
-  }
-
-  getChangeCode () {
-    return {
-      changes: this.props.change.changes,
-      doc: this.props.change.doc
-    };
-  }
-
-  showCopiedMessage (target) {
-    let msg = 'The document ID has been copied to your clipboard.';
-    if (target === 'seq') {
-      msg = 'The document seq number has been copied to your clipboard.';
-    }
-    FauxtonAPI.addNotification({
-      msg: msg,
-      type: 'info',
-      clear: true
-    });
-  }
-
-  render () {
-    const { codeVisible } = this.state;
-    const { change, databaseName } = this.props;
-    const wrapperClass = 'change-wrapper' + (change.isNew ? ' new-change-row' : '');
-
-    return (
-      <div className={wrapperClass}>
-        <div className="change-box" data-id={change.id}>
-          <div className="row-fluid">
-            <div className="span2">seq</div>
-            <div className="span8 change-sequence">{change.seq}</div>
-            <div className="span2 text-right">
-              <Copy
-                uniqueKey={uuid.v4()}
-                text={change.seq.toString()}
-                onClipboardClick={() => this.showCopiedMessage('seq')} />
-            </div>
-          </div>
-
-          <div className="row-fluid">
-            <div className="span2">id</div>
-            <div className="span8">
-              <ChangeID id={change.id} deleted={change.deleted} databaseName={databaseName} />
-            </div>
-            <div className="span2 text-right">
-              <Copy
-                uniqueKey={uuid.v4()}
-                text={change.id}
-                onClipboardClick={() => this.showCopiedMessage('id')} />
-            </div>
-          </div>
-
-          <div className="row-fluid">
-            <div className="span2">deleted</div>
-            <div className="span10">{change.deleted ? 'True' : 'False'}</div>
-          </div>
-
-          <div className="row-fluid">
-            <div className="span2">changes</div>
-            <div className="span10">
-              <button type="button" className='btn btn-small btn-secondary' onClick={this.toggleJSON.bind(this)}>
-                {codeVisible ? 'Close JSON' : 'View JSON'}
-              </button>
-            </div>
-          </div>
-
-          <ChangesCodeTransition
-            codeVisible={this.state.codeVisible}
-            code={this.getChangeCode()}
-          />
-        </div>
-      </div>
-    );
-  }
-}
-
-ChangeRow.propTypes = {
-  change: PropTypes.object,
-  databaseName: PropTypes.string.isRequired
-};
-
-
-export class ChangesCodeTransition extends React.Component {
-  willEnter () {
-    return {
-      opacity: spring(1, presets.gentle),
-      height: spring(160, presets.gentle)
-    };
-  }
-
-  willLeave () {
-    return {
-      opacity: spring(0, presets.gentle),
-      height: spring(0, presets.gentle)
-    };
-  }
-
-  getStyles (prevStyle) {
-    if (!prevStyle && this.props.codeVisible) {
-      return [{
-        key: '1',
-        style: this.willEnter()
-      }];
-    }
-
-    if (!prevStyle && !this.props.codeVisible) {
-      return [{
-        key: '1',
-        style: this.willLeave()
-      }];
-    }
-    return prevStyle.map(item => {
-      return {
-        key: '1',
-        style: item.style
-      };
-    });
-  }
-
-  getChildren (items) {
-    const code =  items.map(({style}) => {
-      if (this.props.codeVisible === false && style.opacity === 0) {
-        return null;
-      }
-      return (
-        <div key='1' style={{opacity: style.opacity, height: style.height + 'px'}}>
-          <Components.CodeFormat
-            code={this.props.code}
-          />
-        </div>
-      );
-    });
-
-    return (
-      <span>
-        {code}
-      </span>
-    );
-  }
-
-  render () {
-    return (
-      <TransitionMotion
-        styles={this.getStyles()}
-        willLeave={this.willLeave}
-        willEnter={this.willEnter}
-      >
-        {this.getChildren.bind(this)}
-      </TransitionMotion>
-    );
-  }
-}
-
-
-class ChangeID extends React.Component {
-  render () {
-    const { deleted, id, databaseName } = this.props;
-    if (deleted) {
-      return (
-        <span className="js-doc-id">{id}</span>
-      );
-    }
-    const link = '#' + FauxtonAPI.urls('document', 'app', databaseName, id);
-    return (
-      <a href={link} className="js-doc-link">{id}</a>
-    );
-  }
-}
-
-
-export default {
-  ChangesController,
-  ChangesTabContent,
-  ChangeRow,
-  ChangeID
-};
diff --git a/app/addons/documents/changes/components/AddFilterForm.js b/app/addons/documents/changes/components/AddFilterForm.js
new file mode 100644
index 0000000..8e0b795
--- /dev/null
+++ b/app/addons/documents/changes/components/AddFilterForm.js
@@ -0,0 +1,93 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+export default class AddFilterForm extends React.Component {
+  constructor (props) {
+    super(props);
+    this.state = {
+      filter: '',
+      error: false
+    };
+    this.submitForm = this.submitForm.bind(this);
+  }
+
+  submitForm (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    if (this.props.hasFilter(this.state.filter)) {
+      this.setState({ error: true });
+
+      // Yuck. This removes the class after the effect has completed so it can occur again. The
+      // other option is to use jQuery to add the flash. This seemed slightly less crumby
+      let component = this;
+      setTimeout(function () {
+        component.setState({ error: false });
+      }, 1000);
+    } else {
+      this.props.addFilter(this.state.filter);
+      this.setState({ filter: '', error: false });
+    }
+  }
+
+  componentDidMount () {
+    this.focusFilterField();
+  }
+
+  componentDidUpdate () {
+    this.focusFilterField();
+  }
+
+  focusFilterField () {
+    this.addItem.focus();
+  }
+
+  inputClassNames () {
+    let className = 'js-changes-filter-field';
+    if (this.state.error) {
+      className += ' errorHighlight';
+    }
+    return className;
+  }
+
+  render () {
+    return (
+      <form className="form-inline js-filter-form" onSubmit={this.submitForm}>
+        <fieldset>
+          <i className="fonticon-filter" />
+          <input
+            type="text"
+            ref={node => this.addItem = node}
+            className={this.inputClassNames()}
+            placeholder="Sequence or ID"
+            onChange={(e) => this.setState({ filter: e.target.value })}
+            value={this.state.filter} />
+          <button type="submit" className="btn btn-secondary">Filter</button>
+          <div className="help-block"></div>
+        </fieldset>
+      </form>
+    );
+  }
+}
+
+AddFilterForm.propTypes = {
+  addFilter: PropTypes.func.isRequired,
+  hasFilter: PropTypes.func.isRequired,
+  tooltips: PropTypes.string
+};
+AddFilterForm.defaultProps = {
+  tooltip: ''
+};
diff --git a/app/addons/documents/changes/components/ChangeID.js b/app/addons/documents/changes/components/ChangeID.js
new file mode 100644
index 0000000..79c8a33
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangeID.js
@@ -0,0 +1,29 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import React from 'react';
+import FauxtonAPI from '../../../../core/api';
+
+export default class ChangeID extends React.Component {
+  render () {
+    const { deleted, id, databaseName } = this.props;
+    if (deleted) {
+      return (
+        <span className="js-doc-id">{id}</span>
+      );
+    }
+    const link = '#' + FauxtonAPI.urls('document', 'app', databaseName, id);
+    return (
+      <a href={link} className="js-doc-link">{id}</a>
+    );
+  }
+}
diff --git a/app/addons/documents/changes/components/ChangeRow.js b/app/addons/documents/changes/components/ChangeRow.js
new file mode 100644
index 0000000..e6d30fe
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangeRow.js
@@ -0,0 +1,120 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import uuid from 'uuid';
+import FauxtonAPI from '../../../../core/api';
+import Components from '../../../fauxton/components';
+import ReactComponents from '../../../components/react-components';
+import ChangesCodeTransition from './ChangesCodeTransition';
+import ChangeID from './ChangeID';
+
+const {Copy} = ReactComponents;
+
+export default class ChangeRow extends React.Component {
+  constructor (props) {
+    super(props);
+    this.state = {
+      codeVisible: false
+    };
+  }
+
+  toggleJSON (e) {
+    e.preventDefault();
+    this.setState({ codeVisible: !this.state.codeVisible });
+  }
+
+  getChangesCode () {
+    return (this.state.codeVisible) ? <Components.CodeFormat key="changesCodeSection" code={this.getChangeCode()} /> : null;
+  }
+
+  getChangeCode () {
+    return {
+      changes: this.props.change.changes,
+      doc: this.props.change.doc
+    };
+  }
+
+  showCopiedMessage (target) {
+    let msg = 'The document ID has been copied to your clipboard.';
+    if (target === 'seq') {
+      msg = 'The document seq number has been copied to your clipboard.';
+    }
+    FauxtonAPI.addNotification({
+      msg: msg,
+      type: 'info',
+      clear: true
+    });
+  }
+
+  render () {
+    const { codeVisible } = this.state;
+    const { change, databaseName } = this.props;
+    const wrapperClass = 'change-wrapper' + (change.isNew ? ' new-change-row' : '');
+
+    return (
+      <div className={wrapperClass}>
+        <div className="change-box" data-id={change.id}>
+          <div className="row-fluid">
+            <div className="span2">seq</div>
+            <div className="span8 change-sequence">{change.seq}</div>
+            <div className="span2 text-right">
+              <Copy
+                uniqueKey={uuid.v4()}
+                text={change.seq.toString()}
+                onClipboardClick={() => this.showCopiedMessage('seq')} />
+            </div>
+          </div>
+
+          <div className="row-fluid">
+            <div className="span2">id</div>
+            <div className="span8">
+              <ChangeID id={change.id} deleted={change.deleted} databaseName={databaseName} />
+            </div>
+            <div className="span2 text-right">
+              <Copy
+                uniqueKey={uuid.v4()}
+                text={change.id}
+                onClipboardClick={() => this.showCopiedMessage('id')} />
+            </div>
+          </div>
+
+          <div className="row-fluid">
+            <div className="span2">deleted</div>
+            <div className="span10">{change.deleted ? 'True' : 'False'}</div>
+          </div>
+
+          <div className="row-fluid">
+            <div className="span2">changes</div>
+            <div className="span10">
+              <button type="button" className='btn btn-small btn-secondary' onClick={this.toggleJSON.bind(this)}>
+                {codeVisible ? 'Close JSON' : 'View JSON'}
+              </button>
+            </div>
+          </div>
+
+          <ChangesCodeTransition
+            codeVisible={this.state.codeVisible}
+            code={this.getChangeCode()}
+          />
+        </div>
+      </div>
+    );
+  }
+}
+
+ChangeRow.propTypes = {
+  change: PropTypes.object,
+  databaseName: PropTypes.string.isRequired
+};
diff --git a/app/addons/documents/changes/components/ChangesCodeTransition.js b/app/addons/documents/changes/components/ChangesCodeTransition.js
new file mode 100644
index 0000000..09ba58d
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesCodeTransition.js
@@ -0,0 +1,94 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import Components from '../../../fauxton/components';
+import {TransitionMotion, spring, presets} from 'react-motion';
+import '../../../../../assets/js/plugins/prettify';
+
+export default class ChangesCodeTransition extends React.Component {
+  willEnter () {
+    return {
+      opacity: spring(1, presets.gentle),
+      height: spring(160, presets.gentle)
+    };
+  }
+
+  willLeave () {
+    return {
+      opacity: spring(0, presets.gentle),
+      height: spring(0, presets.gentle)
+    };
+  }
+
+  getStyles (prevStyle) {
+    if (!prevStyle && this.props.codeVisible) {
+      return [{
+        key: '1',
+        style: this.willEnter()
+      }];
+    }
+
+    if (!prevStyle && !this.props.codeVisible) {
+      return [{
+        key: '1',
+        style: this.willLeave()
+      }];
+    }
+    return prevStyle.map(item => {
+      return {
+        key: '1',
+        style: item.style
+      };
+    });
+  }
+
+  getChildren (items) {
+    const code =  items.map(({style}) => {
+      if (this.props.codeVisible === false && style.opacity === 0) {
+        return null;
+      }
+      return (
+        <div key='1' style={{opacity: style.opacity, height: style.height + 'px'}}>
+          <Components.CodeFormat
+            code={this.props.code}
+          />
+        </div>
+      );
+    });
+
+    return (
+      <span>
+        {code}
+      </span>
+    );
+  }
+
+  render () {
+    return (
+      <TransitionMotion
+        styles={this.getStyles()}
+        willLeave={this.willLeave}
+        willEnter={this.willEnter}
+      >
+        {this.getChildren.bind(this)}
+      </TransitionMotion>
+    );
+  }
+}
+
+ChangesCodeTransition.propTypes = {
+  code: PropTypes.object.isRequired,
+  codeVisible: PropTypes.bool.isRequired
+};
diff --git a/app/addons/documents/changes/components/ChangesContainer.js b/app/addons/documents/changes/components/ChangesContainer.js
new file mode 100644
index 0000000..3fa7ce3
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesContainer.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import ChangesScreen from './ChangesScreen';
+import Actions from '../actions';
+
+const mapStateToProps = ({ changes }, ownProps) => {
+  return {
+    changes: changes.filteredChanges,
+    loaded: changes.isLoaded,
+    databaseName: ownProps.databaseName,
+    isShowingSubset: changes.showingSubset
+  };
+};
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    loadChanges: (databaseName) => {
+      dispatch(Actions.loadChanges(databaseName));
+    }
+  };
+};
+
+const ChangesContainer = connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(ChangesScreen);
+
+export default ChangesContainer;
diff --git a/app/addons/documents/changes/components/ChangesScreen.js b/app/addons/documents/changes/components/ChangesScreen.js
new file mode 100644
index 0000000..8a78d37
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesScreen.js
@@ -0,0 +1,67 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ChangeRow from './ChangeRow';
+
+export default class ChangesScreen extends React.Component {
+  constructor (props) {
+    super(props);
+    this.props.loadChanges(this.props.databaseName);
+  }
+
+  showingSubsetMsg () {
+    const { isShowingSubset, changes } = this.props;
+    let msg = '';
+    if (isShowingSubset) {
+      let numChanges = changes.length;
+      msg = <p className="changes-result-limit">Limiting results to latest <b>{numChanges}</b> changes.</p>;
+    }
+    return msg;
+  }
+
+  getRows () {
+    const { changes, loaded, databaseName } = this.props;
+    if (!changes.length && loaded) {
+      return (
+        <p className="no-doc-changes">
+          There are no document changes to display.
+        </p>
+      );
+    }
+
+    return changes.map((change, i) => {
+      return <ChangeRow change={change} key={i} databaseName={databaseName} />;
+    });
+  }
+
+  render () {
+    return (
+      <div>
+        <div className="js-changes-view">
+          {this.showingSubsetMsg()}
+          {this.getRows()}
+        </div>
+      </div>
+    );
+  }
+}
+
+ChangesScreen.propTypes = {
+  changes: PropTypes.array.isRequired,
+  loaded: PropTypes.bool.isRequired,
+  databaseName: PropTypes.string.isRequired,
+  isShowingSubset: PropTypes.bool.isRequired,
+  loadChanges: PropTypes.func.isRequired
+};
diff --git a/app/addons/documents/changes/components/ChangesTabContent.js b/app/addons/documents/changes/components/ChangesTabContent.js
new file mode 100644
index 0000000..205fe44
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesTabContent.js
@@ -0,0 +1,52 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ReactComponents from '../../../components/react-components';
+import AddFilterForm from './AddFilterForm';
+
+export default class ChangesTabContent extends React.Component {
+  constructor (props) {
+    super(props);
+    this.addFilter = this.addFilter.bind(this);
+    this.hasFilter = this.hasFilter.bind(this);
+  }
+
+  addFilter (newFilter) {
+    if (_.isEmpty(newFilter)) {
+      return;
+    }
+    this.props.addFilter(newFilter);
+  }
+
+  hasFilter (filter) {
+    return this.props.filters.includes(filter);
+  }
+
+  render () {
+    return (
+      <div className="changes-header">
+        <AddFilterForm filter={(label) => this.props.removeFilter(label)} addFilter={this.addFilter}
+          hasFilter={this.hasFilter} />
+        <ReactComponents.BadgeList elements={this.props.filters} removeBadge={(label) => this.props.removeFilter(label)} />
+      </div>
+    );
+  }
+}
+
+ChangesTabContent.propTypes = {
+  filters: PropTypes.array.isRequired,
+  addFilter: PropTypes.func.isRequired,
+  removeFilter: PropTypes.func.isRequired
+};
diff --git a/app/addons/documents/changes/components/ChangesTabContentContainer.js b/app/addons/documents/changes/components/ChangesTabContentContainer.js
new file mode 100644
index 0000000..ed222b4
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesTabContentContainer.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import ChangesTabContent from './ChangesTabContent';
+import Actions from '../actions';
+
+const mapStateToProps = ({ changes }) => {
+  return {
+    filters: changes.filters
+  };
+};
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    addFilter: (filter) => {
+      dispatch(Actions.addFilter(filter));
+    },
+
+    removeFilter: (filter) => {
+      dispatch(Actions.removeFilter(filter));
+    }
+  };
+};
+
+const ChangesTabContentContainer = connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(ChangesTabContent);
+
+export default ChangesTabContentContainer;
diff --git a/app/addons/documents/changes/reducers.js b/app/addons/documents/changes/reducers.js
new file mode 100644
index 0000000..8bcf9f6
--- /dev/null
+++ b/app/addons/documents/changes/reducers.js
@@ -0,0 +1,126 @@
+
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+import ActionTypes from './actiontypes';
+import Helpers from '../helpers';
+
+const initialState = {
+  isLoaded: false,
+  filters: [],
+  changes: [],
+  filteredChanges: [],
+  maxChangesListed: 100,
+  showingSubset: false,
+  lastSequenceNum: null
+};
+
+function updateChanges(state, seqNum, changes) {
+  const newState = {
+    ...state,
+    // make a note of the most recent sequence number. This is used for a point of reference for polling for new changes
+    lastSequenceNum: seqNum,
+    isLoaded: true
+  };
+
+  // mark any additional changes that come after first page load as "new" so we can add a nice highlight effect
+  // when the new row is rendered
+  const firstBatch = newState.changes.length === 0;
+  newState.changes.forEach((change) => {
+    change.isNew = false;
+  });
+
+  const newChanges = changes.map((change) => {
+    const seq = Helpers.getSeqNum(change.seq);
+    return {
+      id: change.id,
+      seq: seq,
+      deleted: _.has(change, 'deleted') ? change.deleted : false,
+      changes: change.changes,
+      doc: change.doc, // only populated with ?include_docs=true
+      isNew: !firstBatch
+    };
+  });
+
+  // add the new changes to the start of the list
+  newState.changes = newChanges.concat(newState.changes);
+  updateFilteredChanges(newState);
+  return newState;
+}
+
+function addFilter(state, filter) {
+  const newFilters = state.filters.slice();
+  newFilters.push(filter);
+
+  const newState = {
+    ...state,
+    filters: newFilters
+  };
+  updateFilteredChanges(newState);
+  return newState;
+}
+
+function removeFilter(state, filter) {
+  const newFilters = state.filters.slice();
+  const idx = newFilters.indexOf(filter);
+  if (idx >= 0) {
+    newFilters.splice(idx, 1);
+  }
+
+  const newState = {
+    ...state,
+    filters: newFilters
+  };
+  updateFilteredChanges(newState);
+  return newState;
+}
+
+function updateFilteredChanges(state) {
+  state.showingSubset = false;
+  let numMatches = 0;
+  state.filteredChanges = state.changes.filter((change) => {
+    if (numMatches >= state.maxChangesListed) {
+      state.showingSubset = true;
+      return false;
+    }
+    let changeStr = JSON.stringify(change);
+    let match = state.filters.every((filter) => {
+      return new RegExp(filter, 'i').test(changeStr);
+    });
+
+    if (match) {
+      numMatches++;
+    }
+    return match;
+  });
+}
+
+export default function changes (state = initialState, action) {
+  switch (action.type) {
+
+    case ActionTypes.UPDATE_CHANGES:
+      // only bother updating the list of changes if the seq num has changed
+      if (state.lastSequenceNum !== action.seqNum) {
+        return updateChanges(state, action.seqNum, action.changes);
+      }
+      return state;
+
+    case ActionTypes.ADD_CHANGES_FILTER_ITEM:
+      return addFilter(state, action.filter);
+
+    case ActionTypes.REMOVE_CHANGES_FILTER_ITEM:
+      return removeFilter(state, action.filter);
+
+    default:
+      return state;
+  }
+}
diff --git a/app/addons/documents/changes/stores.js b/app/addons/documents/changes/stores.js
deleted file mode 100644
index 7c204ac..0000000
--- a/app/addons/documents/changes/stores.js
+++ /dev/null
@@ -1,157 +0,0 @@
-
-// Licensed under the Apache License, Version 2.0 (the "License"); you may not
-// use this file except in compliance with the License. You may obtain a copy of
-// the License at
-//
-//   http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-// License for the specific language governing permissions and limitations under
-// the License.
-
-import FauxtonAPI from "../../../core/api";
-import ActionTypes from "./actiontypes";
-import Helpers from "../helpers";
-
-
-var ChangesStore = FauxtonAPI.Store.extend({
-  initialize: function () {
-    this.reset();
-  },
-
-  reset: function () {
-    this._isLoaded = false;
-    this._filters = [];
-    this._changes = [];
-    this._databaseName = '';
-    this._maxChangesListed = 100;
-    this._showingSubset = false;
-    this._lastSequenceNum = null;
-  },
-
-  initChanges: function (options) {
-    this.reset();
-    this._databaseName = options.databaseName;
-  },
-
-  isLoaded: function () {
-    return this._isLoaded;
-  },
-
-  updateChanges: function (seqNum, changes) {
-
-    // make a note of the most recent sequence number. This is used for a point of reference for polling for new changes
-    this._lastSequenceNum = seqNum;
-
-    // mark any additional changes that come after first page load as "new" so we can add a nice highlight effect
-    // when the new row is rendered
-    var firstBatch = this._changes.length === 0;
-    _.each(this._changes, (change) => {
-      change.isNew = false;
-    });
-
-    var newChanges = _.map(changes, (change) => {
-      var seq = Helpers.getSeqNum(change.seq);
-      return {
-        id: change.id,
-        seq: seq,
-        deleted: _.has(change, 'deleted') ? change.deleted : false,
-        changes: change.changes,
-        doc: change.doc, // only populated with ?include_docs=true
-        isNew: !firstBatch
-      };
-    });
-
-    // add the new changes to the start of the list
-    this._changes = newChanges.concat(this._changes);
-    this._isLoaded = true;
-  },
-
-  getChanges: function () {
-    this._showingSubset = false;
-    var numMatches = 0;
-
-    return _.filter(this._changes, (change) => {
-      if (numMatches >= this._maxChangesListed) {
-        this._showingSubset = true;
-        return false;
-      }
-      var changeStr = JSON.stringify(change);
-      var match = _.every(this._filters, (filter) => {
-        return new RegExp(filter, 'i').test(changeStr);
-      });
-
-      if (match) {
-        numMatches++;
-      }
-      return match;
-    });
-  },
-
-  addFilter: function (filter) {
-    this._filters.push(filter);
-  },
-
-  removeFilter: function (filter) {
-    this._filters = _.without(this._filters, filter);
-  },
-
-  getFilters: function () {
-    return this._filters;
-  },
-
-  hasFilter: function (filter) {
-    return _.includes(this._filters, filter);
-  },
-
-  getDatabaseName: function () {
-    return this._databaseName;
-  },
-
-  isShowingSubset: function () {
-    return this._showingSubset;
-  },
-
-  // added to speed up the tests
-  setMaxChanges: function (num) {
-    this._maxChangesListed = num;
-  },
-
-  getLastSeqNum: function () {
-    return this._lastSequenceNum;
-  },
-
-  dispatch: function (action) {
-    switch (action.type) {
-      case ActionTypes.INIT_CHANGES:
-        this.initChanges(action.options);
-        break;
-
-      case ActionTypes.UPDATE_CHANGES:
-        this.updateChanges(action.seqNum, action.changes);
-        break;
-
-      case ActionTypes.ADD_CHANGES_FILTER_ITEM:
-        this.addFilter(action.filter);
-        break;
-
-      case ActionTypes.REMOVE_CHANGES_FILTER_ITEM:
-        this.removeFilter(action.filter);
-        break;
-
-      default:
-        return;
-    }
-
-    this.triggerChange();
-  }
-});
-
-
-var Stores = {};
-Stores.changesStore = new ChangesStore();
-Stores.changesStore.dispatchToken = FauxtonAPI.dispatcher.register(Stores.changesStore.dispatch.bind(Stores.changesStore));
-
-export default Stores;
diff --git a/app/addons/documents/layouts.js b/app/addons/documents/layouts.js
index bb11d66..3b921aa 100644
--- a/app/addons/documents/layouts.js
+++ b/app/addons/documents/layouts.js
@@ -16,7 +16,8 @@
 import { NotificationCenterButton } from '../fauxton/notifications/notifications';
 import SidebarControllerContainer from "./sidebar/SidebarControllerContainer";
 import HeaderDocsLeft from './components/header-docs-left';
-import Changes from './changes/components';
+import ChangesContainer from './changes/components/ChangesContainer';
+import ChangesTabContentContainer from './changes/components/ChangesTabContentContainer';
 import IndexEditorComponents from "./index-editor/components";
 import DesignDocInfoContainer from './designdocinfo/components/DesignDocInfoContainer';
 import RightAllDocsHeader from './components/header-docs-right';
@@ -105,7 +106,7 @@
   showPartitionKeySelector: PropTypes.bool.isRequired,
   partitionKey: PropTypes.string,
   onPartitionKeySelected: PropTypes.func,
-  onGlobalModeSelected: PropTypes.bool,
+  onGlobalModeSelected: PropTypes.func,
   globalMode: PropTypes.bool
 };
 
@@ -228,8 +229,8 @@
         hideQueryOptions={true}
       />
       <TabsSidebarContent
-        upperContent={<Changes.ChangesTabContent />}
-        lowerContent={<Changes.ChangesController />}
+        upperContent={<ChangesTabContentContainer />}
+        lowerContent={<ChangesContainer databaseName={dbName}/>}
         hideFooter={true}
         selectedNavItem={selectedNavItem}
       />
diff --git a/app/addons/documents/routes-documents.js b/app/addons/documents/routes-documents.js
index e61fab7..9def382 100644
--- a/app/addons/documents/routes-documents.js
+++ b/app/addons/documents/routes-documents.js
@@ -13,7 +13,6 @@
 import React from 'react';
 import FauxtonAPI from '../../core/api';
 import BaseRoute from './shared-routes';
-import ChangesActions from './changes/actions';
 import Databases from '../databases/base';
 import Resources from './resources';
 import {SidebarItemSelection} from './sidebar/helpers';
@@ -131,9 +130,6 @@
   },
 
   changes: function () {
-    ChangesActions.initChanges({
-      databaseName: this.database.id
-    });
     const selectedNavItem = new SidebarItemSelection('changes');
 
     return <ChangesSidebarLayout