Skip to content

UI Data Architecture

pospi edited this page Mar 31, 2017 · 1 revision

The data layer of the UI is built using Redux. We use a few other modules to give more structure and reduce verbosity in the data layer of the app.

Basic architecture

This is pretty standard behaviour, but here's where the integration points are:

  • The main Redux entrypoint is src/store/configure.js. This is where the app storage singleton is configured and middlewares are defined.
  • On the server, src/server.js loads the store configuration and applies it to an empty starting app state. Serverside pre-fetching logic optionally attached to route components (the static methods GET, POST etc) is executed and final app state data is rendered to window.__INITIAL_STATE__ before sending the page to the client.
  • On the client, src/client.js picks up the starting state data and inflates the store again (using store/configure.js) to continue execution.

Aside from the main store, there are subfolders for the central Redux concepts- actions, reducers, selectors and sagas. Each contains implementations for features of the app in isolated sections. There is usually a 1:1 correspondence between all folders, but this is not always the case- some reducers may listen to payloads from multiple actions, for example.

We use the following helpers within the storage layer to reduce boilerplate and overall complexity:

redux-action-helper is a utility library which provides some helper functions useful in declaring redux actions and reducers. The best way to get a feel for what this provides is to take a look at some of the store implementations. In brief, it lets you write your reducers more cleanly as toplevel functions instead of having to include a case statement for every action, and makes creating simple kinds of action creators easy.

reselect is a memoizing utility for reading data from Redux state. It will automatically avoid recomputing output if the input arguments haven't changed since last update. So basically it's shouldComponentUpdate() checks made easy. However, there is a caveat with this: when using selectors between different components the cache is invalidated by each one if they provide different props to the selector. To workaround this problem you can use re-reselect to get more control over the caching behaviour.

Here are some guidelines to follow when authoring the data layer:

Constants

Constants are the string IDs Redux uses internally to coordinate between actions and reducers. We have 1 big Redux constants file to make things easier to follow. See src/store/constants.

Actions

These should build up a self-documenting reference of the actions that can be performed by the UI (and by services). Each module defines an 'action creator', which is just a function which returns a specially formatted action object. Other than that we define some Flow typings for the input and output parameters so we can validate those in dependant parts of the app. redux-action-helper has a few action-related helpers we use to reduce verbosity when writing these.

Reducers

Reducers are the most critical piece of the Redux architecture- they are where you define your storage data structure and update logic.

We define our reducers using redux-action-helper's createReducer and createLeaf helpers, which essentially allow us to define action handling behaviour as individual top-level functions instead of having 1 giant function with a switch statement in it.

The primary gotcha to be aware of in reducers is don't use mutative operations - such as Array.push & others. If you alter the previous state in the reducer whilst returning the next updated state, Redux & React & anything else won't be able to tell the state has changed anymore. This can lead to all kinds of weirdness and unexpected bugs.

You will probably notice heavy use of ES6 destructuring in the reducer code, which gives us a quick and reasonably pleasant way of copying objects. In particular, you will always:

  • Define your initialState and give it a type, but provide { ...initialState } when creating your reducer to ensure the values are copied to a new object.
  • Use { ...state, changedProp: newValue } in your action handlers (see createLeaf) to create a copy of the state object instead of mutating it.

In future we might decide to use Immutable or a similar library to prevent accidental state mutations, but until we have a larger development team and start encountering those kinds of bugs it's probably better to keep our build as lean as possible.

Selectors

These are pretty simple, they're just functions which accept the (entire) application state and return some (potentially modified) portion of it for access in the UI. In this simplest form, this is all they do.

More complex selectors will benefit greatly from reselect library as it can be used to memoize (ie. cache) results from previous operations. In essence they are the same though, the data just passes through more transform steps before being returned.

Sagas

Sagas are where you define the asyncronous actions that occur in your app. Think of them as the "internal actions" which run in response to the "external actions" you've defined in /actions. They can be composed of any control flow, but usually represent server data fetching actions. For more info, see the redux-saga middleware notes below.

Note that your reducers will need to be bound to both events from actions and from sagas- with an originating action usually setting some variable to indicate that it has begun and _failed/_success actions which fire on completion. In most cases you'll want your UI to be aware of all three possible states!

Middlewares

Here are the middlewares currently configured in Redux's store and the purpose of each:

  • redux-saga gives us a defined and simple way of managing asynchronous actions.
  • redux-thunk is some simple middleware to allow returning functions as actions from the UI. The function will be executed until an action object is eventually dispatched. Note that sagas could be implemented directly on thunks- they are a lower-level concept that allows many things to be more readily integrated with Redux.
  • react-router-redux isn't something you should have to interact with, it just tracks the URL in Redux so that the Redux state & time-travel debugging also syncs with the location.
  • remote-redux-devtools is a development plugin which handles connecting your app to RemoteDev. See the app README for more details on debugging.