Yihang Ho bio photo

Yihang Ho

Coder

Twitter Github Email

Note: This post is not about what Redux is or how to use Redux. This is written under the assumption that you have decided that Redux is a good fit for the problem you are trying to solve.

State Tree

Spend time to design your state tree. The state tree is supposed to be the single source of truth in your application. If it is screwed, it is axiomatic that the rest of the app is screwed too.

Storing Resources

Usually, a big chunk of the state tree is used to store resources (i.e. objects loaded from your backend server). I design the tree such that each class of resources is stored in a branch in the state tree and has the following shape:

{
  [resourceName]: {
    byId: {
      [id1]: {
        id: id1,
        ...otherProperties
      },
      [id2]: {
        id: id2,
        ...otherProperties
      }
    },
    lastFetched: new Date(),
    isFetching: false,
    ...otherUsefulProps
  }
}

There are three main properties:

  1. byId - This is an object that maps an instance's ID into the instance itself. This representation might not be intuitive as usually the server returns the list as an array. There is nothing wrong with using an array, but using an object is much more convenient when we wish to, say, get an instance with a particular ID. Each of the objects here should be denormalized, in other words, in cases where they are associated to other objects, keep the IDs to those objects instead of instances of them. Notice that they keys in JavaScript objects are always strings, hence, if the IDs are numbers, they will be coerced into string. It might be necessary to do property type-casting in the other parts of the application.
  2. lastFetched - This is set to the time when the last fetch operation is performed. This is used to determine how fresh the data is. The application can then proactively refetch from the server when has been a while since the last fetch.
  3. isFetching - Set to true when a fetch is in progress. This is useful to show a spinner and prevent double fetch.

We can add more properties to this subtree if it makes sense to do so.

Resource Metadata

Sometimes, it makes sense to keep track of some metadata on each object. For example, we might want to know if we are loading a single object. In cases like this, it is very convenient to have a, say, __meta__ property to keep track of these metadata.

Single Source of Truth

Make sure there is no way a state tree can conflict with itself. Sometimes, it might be tempting to store some computed data in the state tree. For e.g., if we are storing a list of messages, each message has an isRead property. It might be tempting to store an unreadCount. The problem here is that now we need to keep synchronize these two properties, and there is a non-zero probability of things going out of sync. This is precisely the kind of problem that React and Redux are trying to solve.

This brings us to the cases when the computation of the derived property is expensive. There are ways to solve this and will be discussed later.

Structural Sharing

Notice that reducers should never mutate the state argument:1

// Don't do this
const reducer = (state = {count: 0}, action) => {
  switch (action.type) {
    case 'INCREMENT':
      state.count += 1;
      return state;
    case 'DECREMENT':
      state.count -= 1;
      return state;
    default:
      return state;
  }
};

The idiomatic way is to always return a new instance of an object if anything changed:

const reducer = (state = {count: 0}, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return {
        count: state.count + 1
      };
    case 'DECREMENT':
      return {
        count: state.count - 1
      };
    default:
      return state;
  }
};

This may or may not cause perf issues.2 If it does, you can consider using libraries that implement structural sharing such as Immutable. However, note that Immutable exposes a set of API that can be quite different from vanilla JS objects and arrays. So use this only if you really need it.

Action Creators

Side-Effects

The designers of Redux explicitly mention that the Redux reducers should be pure. As a result, it can be quite a bit of a hassle to initiate and manage side-effects (such as loading things from the server). One of the simplest solutions is redux-thunk. redux-thunk "allows you to write action creators that return a function instead of an action". This returned function takes in a dispatch function and can dispatches real actions later on. (The dispatch function is the same as that from store.dispatch. redux-thunk is making use of dependency injection to decouple the action creators from the store).

Action creators as functions

Action creators should always be a function even if they do not take in any arguments. For example:

// OK
const fetchPosts = () => (dispatch) => {
  return fetch('/posts')
    .then((response) => response.json())
    .then((posts) => dispatch({
      type: 'RECEIVE_POSTS',
      posts
    }));
};

const toggleMenu = () => ({
  type: 'TOGGLE_MENU'
});

// Not OK
const fetchPosts = (dispatch) => {
  // Same thing as before
};

const toggleMenu = {
  type: 'TOGGLE_MENU'
};

There are two very good reasons:

  1. Consistency - By espousing this convention, you know that whenever you import an action creator, we need to dispatch the result of invoking the action creator, but not the action creator itself.
  2. Backward compatibility in the future - Suppose in the future we wish to add optional params to the action creators (e.g., a page params for fetchPosts when we decided to add pagination). This way, we can set a default value for these params and the old code remain working:

       const fetchPosts = (page = 1) => (dispatch) => {
         // Do important things
       };
    
       // Places that call dispatch(fetchPosts()) continue to work without any
       // modifications.
    

Async action creators

Action creators that are async should always return a promise (or use some async callback pattern if promise is not sufficient). This allows the composition of async action creators:

const someAction = () => (dispatch) => {
  return dispatch(refreshFeed())
    .then(() => dispatch(updateAnalytics()))
    .then(() => dispatch(someOtherAction()));
};

Optimistic updates

To fully exploit the benefit of writing an SPA, we should do optimistic updates whenever possible. One major consideration when performing optimistic update is to do error handling - when 💩 hits the fan. There are a number of ways to do error handling in this context. Here, we assume that we will reverse the update and show an error message. We also need to keep in mind that multiple optimistic updates (of the same actions, such as liking a post) can happen at the same time. So we need a way to keep track of each of this actions. One way to do this is to tag each action with a unique ID. After that, we can swap this update with the real thing or cancel it, both based on this unique ID. For example,

const createPost = (title, content) => (dispatch) => {
  const id = uuid();

  dispatch(insertPost({ id, title, content }));

  api.createPost(title, content)
    .then((post) => {
      dispatch(removePost(id));   // Remove the temp one
      dispatch(insertPost(post)); // Insert the real one

      // Note: This is done for the sake of simplicity and **might not** work
      // for all cases. For example, your React component might be keyed using
      // the post ID here, which will cause a new instance to be used. If this
      // is a problem, there might be a need to have an internal ID. That will
      // not be changed.
    })
    .catch((error) => {
      dispatch(removePost(id));
      // Somehow display error message
    });
};

const reducer = (state = {}, action) => {
  switch (action.type) {
    case 'INSERT_POST':
      return {
        ...state,
        [action.post.id]: action.post
      };
    case 'REMOVE_POST':
      const { [action.id]: garbage, ...newState } = state;
      return newState;
    default:
      return state;
  }
};

Reducers

Testing Your Reducers

Because reducers are pure by default, they are really easy to test:

  1. Use deep-freeze in your test code to make sure that you are not accidentally mutating the state argument.
  2. Use any of the assertion library to do TDD.
  3. Once you are done with your TDD, you can consider using Jest to do snapshot testing to prevent regression.

Updating Nested State

ES has some pretty nice syntax that can be used to updated a nested state object easily without mutation:

Higher-Order Reducers

Quite a number of reducers are actually very straightforward and are repeated many times across the application. For example:

  • Toggle a boolean when a particular action is dispatched.
  • Set the state to null when a particular action is dispatched.
  • Set the state to some property in the action object when a particular action is dispatched.

For cases like this, we can write higher-order reducers to generate these reducers. For example, this higher-order reducer can be used to toggle a boolean state when an action is dispatched:

const makeToggleReducer(initialState, actionType) => {
  return (state = initialState, action) => {
    switch (action.type) {
      case actionType:
        return !state;
      default:
        return state;
    }
  };
};

const reducer = combineReducers({
  showSideMenu: makeToggleReducer(false, 'TOGGLE_SIDE_MENU')
});

Misc

Expensive Re-computation

As mentioned in a previous section, there should only be a single source of truth. This creates problem when other parts of the applications depend on information that is expensive (by some measure) to compute. The example I gave was calculating the number of unread messages from a list of messages.

The first course of action is to not do anything. Although CS education teaches us to not go for O(N) when you can do O(1), if it is not causing any perf issue, then just forget about it.3

However, if you have decided that this computation is indeed causing perf issue, there is an easy fix - use reselect in your mapStateToProps. The idea behind reselect is that if the input to a function does not change, it will just return the cached result, which is a valid assumption for Redux as the reducers should always return a new state instead of mutating it.

const { createSelector } from 'reselect';

const unreadCountSelector = createSelector(
  (state) => state.messages,
  (messages) => messages.reduce((count, {isRead}) => isRead ? count : count + 1, 0)
);

const mapStateToProps = (state) => ({
  unreadCount: unreadCountSelector(state)
});

This way, if the state tree is updated, as long as state.messages is untouched, the selector function will just return the previous results.

setState

When you are using React with Redux, setState is really not an anti-pattern.

In fact, at times, setState is a much more superior option. See, the Redux state tree is essentially a global object. Although we do fancy things like DI and HOC to decouple the store from everything else, the dependency is still there (just that which store they depend on can be varied easily now). When we use setState instead of Redux dispatch, we are removing such dependency and the component we are building becomes more standalone.

setState is the more suitable option when the state is highly localized and is not shared between components. For example, let's say we are implementing a component for the user to select some images from a set of images. The images are grouped (say in groups of four). There are also previous and next buttons to move between groups. Now, it is obvious that the current page number is a state that we have to keep track of somewhere. This state is something that other components do not care about. Hence, it should go into local component state.

For this toy example, using Redux is actually a bad design. If we move the state into the Redux state tree, that means we need to write the action creators to move between pages, a reducer to update the page number, and a container to connect the component to the state and action creators. Now notice that the state is now written into a fixed location in the state tree. What happens if we want to use multiple instances of this component? (Not an unreasonable assumption.) Do we write another set of action creators, reducers, and containers?4

I find it useful to always start from using local component state and refactor the state out to the Redux store only when necessary. This greatly reduces the overall complexity of the application.


  1. One of the practical reasons behind this is that the rest of the Redux ecosystem is making this assumption. So mutating the state will silently break you app. For example, reselect will wrongly return the cached results. 

  2. Usually it doesn't. 

  3. How do you know if it is causing perf issue? Always measure and/or experiment. 

  4. Technically, this can be solved by accepting this location as a props and referencing it in mapStateToProps and/or mapDispatchToProps

    const mapStateToProps = (state, ownProps) => ({
      page: state.gallery[ownProps.name].page
    });
    

    In fact, Redux Form is using such approach. However, we are really just moving the problem one level up - the parent component is now not reusable. (Or we can play the same trick and move it one level up again, but the problem will never go away.)