M3 State Management: Reducers, Actions, Events - ProjectMirador/mirador GitHub Wiki

(Note: There is still discussion around this pattern. This article is a possible documentation for the pattern. Nothing is fixed so far.)

In Mirador 3 we use Redux as a state management library. As Redux is more a general design pattern than it is an opinionated framework, it gives you large freedom to accomplish things. But this freedom comes with a trade-off: You have to come up with your own usage patterns for Redux that fit your needs.

We currently apply a pattern where we treat the Redux store as a simple database with create, update and delete operations. The internals of this operations are encapsulated and accessible through a simple API.

Below is an explanation of this pattern and instructions how to use it.

State

Our global state object is structured in a way similar to a relational database. If you want to know about the reasons for that read this article from the Redux documentation. Here is an example of the state object:

const state = {
  windows: {
    'window-1': {
      id: 'window-1',
      canvasIndex: 5,
      manifestId: 'manifest-1'
    },
    'window-2': {
      id: 'window-2',
      canvasIndex: 2,
      manifestId: 'manifest-2'
    },
  },
  manifests: {
    'manifest-1': {
      id: 'manifest-1',
      json: '...',
    },
    'manifest-2': {
      id: 'manifest-2',
      json: '...',
    }
  },
  config: {
    theme: 'light',
    language: {
      'en': 'English',
      'de': 'German'
    }
  }
}

As you can see, there are three pieces of data at the top level of the state: windows, manifests and config. Let's consider the first two. windows and manifests are database like tables. Each table has a bunch of rows that each have an ID (e.g. window-1 and window-2). Each row has a number of fields (e.g. canvasIndex). For the sake of convenient access, the ID of an row is copied in one of the fields.

If a piece of data has a relation to another piece of data somewhere in the store it can reference this data by ID. For example, the window-1 has a relation to manifest-1 and stores the ID of this manifest in the manifestId field, rather than storing the entire manifest object.

This way the state object has a flat and constitent structure that can be utilized in many places. To promote this strucure, the fields should only consist of primitive data types or arrays of primitive data types. (Complex) objects should not be values of fields, but rather have their own table in the state.

For some application data the table structure is too strict. Take the config object from the example. Obviously there is only one instance of config, not mutliple. To store it in a table that only have a single row seems to be overhead. So, it is fine to have this singleton data type as a plain object in the top level of the state.

Reducers

There is a reducer for each of the data pieces at the top level of the state object. Following the example from above, there is one reducer for windows, one for manifests and one for config. The reducers perform only three operations:

  • a) create an item (or row)
  • b) update an item and
  • c) delete an item

Because each reducer only have this basic operations and because they all process the same data structure (tables or singeltons) we can create them automatically. (See this article from the Redux docs for similar patterns)

The src/state/reducers/createReducers.js file contains two reducer creator functions with a the following signatures:

  • createTableReducer(Object: actionsTypes) -> Function
  • createSingletonReducer(Object: actionsTypes) -> Function

The actionTypes argument is an object that map from supported operations to action type constants. The table reducer supports the create, update and delete operation. Because singleton data (like config) exists from application start to end, the singleton reducer only supports the update operation.

Heres an example how to create a table reducer and a singleton reducer and pass them to the root reducer:

import { combineReducers } from 'redux';
import { createTableReducer, createSingeltonReducer } from './createReducers';

const windowActionTypes = {
  create: 'CREATE_WINDOW',
  update: 'UPDATE_WINDOW',
  delete: 'DELETE_WINDOW',
};

const manifestActionTypes = {
  create: 'CREATE_MANIFEST',
  update: 'UPDATE_MANIFEST',
  delete: 'DELETE_MANIFEST',
};

const configActionTypes = {
  update: 'UPDATE_CONFIG',
};

const rootReducer = combineReducers({
  windows: createTableReducer(windowActionTypes),
  manifests: createTableReducer(manifestActionTypes),
  config: createSingeltonReducer(configActionTypes),
});

Actions

(When we say "actions" we usually mean action creator functions, i.e. function that return an object that must contain a action type constant and can contain additional data.)

In Mirador 3 we distinguish between three types of actions:

  • a) basic actions
  • b) combines actions
  • c) events

Basic Actions

Basic actions are those that can trigger one of the reducer operations (see above). Take for example the table-like windows data: there are a createWindow action, deleteWindow action and a updateWindow action. The singleton config data only has a updateConfig action.

Like the reducers we can create the basic actions automatically. The src/state/reducers/createActions.js file contains two function for that with the following signatures:

  • createTableReducerActions(Object: actionTypes, String: idPrefix, Object: defaultProps) --> Object of Functions
  • createSingletonReducerActions(Object: actionTypes) --> Object of Functions

The actionTypes argument is the same that you pass when creating a reducer (see above). The idPrefix argument is a string that will be prepended to the IDs of the items for the sake of readabillity. defaultProps is an object that holds the default properties of the items to create.

Here is an expample how to create the basic actions via the helper functions:

import { createTableReducerActions, createSingletonReducerActions } from './createActions';
import actionTypes from './actionTypes';

const windowDefaults = {
  canvasIndex: 0,
  manifestId: null,
  rangeId: null,
  xywh: [0, 0, 400, 400],
}

export const {
  createWindow, updateWindow, deleteWindow, 
} = createTableReducerActions(actionTypes.window, 'window', windowDefaults);

export const {
  createManifest, updateManifest, deleteManifest, 
} = createTableReducerActions(actionTypes.manifest, 'manifest');

export const {
  updateConfig
} = createSingletonReducerActions(actionTypes.config);

The actions returned by the helper functions have consistent signatures:

FOR TABLE REDUCER

addItem

  • @param {Object} payload - Data that will be set to the item by the table reducer. It gets shallow merged with the default properties and therefore may overrides the defaults.

  • @param {String} id - Optional. Sets the item ID explicitly. Otherwise the ID will be created automatically.

updateItem

  • @param {String} id - ID of item to be updated.

  • @param {Object} payload - Update data. It gets deep merged with the existing item data by the table reducer.

deleteItem

  • @param {String} id - ID of item to be deleted.

FOR SINGLETON REDUCER

updateItem

  • @param {Object} payload - Update data. It gets deep merged with the existing item data by the singleton reducer.

Note: Basic actions are pure database actions. They should not be exposed to the react components or the corresponding container components. Rather they should be used by the combined actions (see below) to perform more specific application actions.

Combined Actions

While basic actions perform database logic like create or update, combined actions are meant to perform application logic like openWindow or changeManifest. They use the basic actions to accomplish this things.

We currently using the redux-thunk library to build combined actions. Thunks provide access to the dispatch and getState function of the redux store. This way, combined actions can perform complex state management logic within a single function.

Here is a example for a combined action. The goal is to close a window. As the window holds references to a bunch of companion windows that become obsolete when the window is closed, the companion windows has to be deleted too in this step.

import * as basics from '../reducers/basicActions';

const closeWindow = windowId => (dispatch, getState) => {
  const { companionIds } = getState().windows[windowId];
  companionIds.forEach(id => dispatch(basics.deleteCompanion(id)));
  dispatch(basics.deleteWindow(windowId));
}

Events

There is ongoing work for a plugin system in Mirador 3. One requirement for plugins is that they should be able to listen and react to events that happen in the application. In the current approach a plugin can inject a custom reducer to the Redux store of Mirador and then intercept certain action types. With the state managment pattern that is described in this document so far, a plugin reducer would only be able to intercept the basic action types like DELETE_WINDOW or UPDATE_CONFIG, but would not be able to react on a more specific event like WINDOW_CLOSED as there is no such action type.

To address this problem there are events. An event in Mirador is an action that informs that something has happened an can provide some related data, but it does not change the state of the application. As events are ordinary Redux actions the action type they provide can be intercepted by any reducer.

Here is an example of how to define, fire and intercept an event.

// in an event file
export const windowClosed(manifestId) {
  return { type: 'EVENT_WINDOW_CLOSED', manifestId }
}

// in a combined actions file
const closeWindow = windowId => (dispatch, getState) => {
  const { manifestId } = getState().window[windowId];
  const { companionIds } = getState().windows[windowId];
  companionIds.forEach(id => dispatch(basics.deleteCompanion(id)));
  dispatch(basics.deleteWindow(windowId));
  // fire event
  dispatch(events.windowClosed(manifestId));
}

// in a plugin reducer file
const manifestHistoryReducer(state = [], action) {
  if (action.type === 'EVENT_WINDOW_CLOSED') {
    return [ ...state, action.manifestId ]
  }
  return state;
}