Deep state changes with Immer

(no, not that deep state)

One of the tedious aspects of writing reducers is handling changes to deeply nested objects. Deeply nested state is somewhat frowned upon, but I do a lot of work with Mapbox and managing MapStyle (an object with lots of map configuration objects inside it) through a reducer helps a lot with integrating a fairly imperative library (mapbox-gl-js) into a React app.

An example

To allow a user to click the map and see a point appear, the click event must create a GeoJSON Point feature:

const clickedPoint = {
  type: "Feature",
  properties: {},
  geometry: {
    type: "Point",
    coordinates: [
      -85.363274,
      38.1680294
    ]
  }
}

and this feature must find its way into a GeoJSON source object inside MapStyle.

// Straight-up mutating mapStyle.
mapStyle.sources["clickPoints"].data.features.push(clickedPoint);

In a reducer, you cannot just mutate a deeply nested object and return the previous state.

…you need to create a copied and updated object for each level of nesting that is affected. Although that shouldn’t be particularly expensive, it’s another good reason why you should keep your state normalized and shallow if possible. - the Redux docs

With lots of nesting, creating copies/updates for each level is super annoying. However, there is a shortcut: deep clone the previous state, then mutate and return the clone. If your state is JSON serializable, deep cloning is as easy as JSON.parse(JSON.stringify(state)).

// Imagine this is in a reducer with (state, action) in scope
const newMapStyle = JSON.parse(JSON.stringify(state.mapStyle));
newMapStyle.sources["clickPoints"].data.features.push(action.clickedPoint);
return {...state, mapStyle: newMapStyle};

This shortcut works well, but deep cloning the entire state can be costly. Not only in the cloning operation itself, but also in how React/Redux detects changes.

In addition, deep cloning state creates new references for every field. Since the React-Redux connect function relies on reference comparisons to determine if data has changed, this means that UI components will be forced to re-render unnecessarily even though the other data hasn’t meaningfully changed. - the Redux docs

One of the mapping projects I’ve worked on at Corteva kept MapStyle in an Immutable.js data structure (rather than a plan object). Immutable is a more performant way of making deep state changes in a reducer. However, it requires learning a whole new API and the methods can be quite verbose and easy to mistype.

Immer

Immer, like Immutable.js, can be used to make deep state changes in reducers but Immer has a completely different approach.

import produce from "immer";

// Again, imagine we are in a reducer with (state, action) in scope
return produce(state, draftState => {
  const clickPoints = draftState.mapStyle.sources["clickPoints"];
  clickPoints.data.features.push(action.clickedPoint);
});

Instead of having to deal with a whole bunch of Immutable.js data structures with their special methods, all you need to do is supply produce() with the current state and a callback. The callback will be passed a copy of the current state (called draftState) which can be mutated.

Immer makes it delightfully easy to make deep state changes in a reducer and has become my goto for handling such cases.