Manipulate state in Redux, but not with ImmutableJS

Problems When Handling Redux State

Here is what we do now:

function reducer(s, action) {
  const state = Object.assign({}, s);

  switch (action.type ) {
    ...
  }

  return state;
}

The biggest problem above is that the state will change whenever an action is fired. We can improve it like below:

function reducer(s, action) {
  let state = s;

  switch (action.type ) {
    case 'CHANGE_NAME':
    state = Object.assign({
      name: action.payload.name,
    }, state);
  }

  return state;
}

This will solve the problem, and we can also use the ES6 Spread sytax to make it prettier:

function reducer(s, action) {
  let state = s;

  switch (action.type ) {
    case 'CHANGE_NAME':
    state = {
      ...state,
      name: action.payload.name,
    };
  }

  return state;
}

Looks better, but you need to remember to do it in every case. And the code will become a lot of more cumbersome if you want to change something very nested:

function reducer(s, action) {
  let state = s;

  switch (action.type ) {
    case 'CHANGE_MEMBERS_NAME':
    state = {
      ...state,
      members: {
        ...state.members
        [action.payload.id]: {
          ...state.members[action.payload.id],
          name: action.payload.name,
        },
      },
    };
  }

  return state;
}

Why do we need to do that, can we just?:

state.members[action.payload.id].name = action.payload.name;
state = {
  ...state,
};

Either way will trigger the state to change, and will revoke your mapStateToProps method passed into the connect function, but since the state.memebers and the state.members[action.payload.id] is still using the original reference, the corresponding UI Components will not re-render, because they will treat the props passed in as “unchanged”.

Another problem we are facing is when we need to access a nested path in a object, for example:

function mapStateToProps(state) {
  return {
    teamId: state.auth.user.team.id,
  };
}

The code above will fail if any of the path above is not exist, you have to do it like:

return {
  teamId: state && state.auth && state.auth.user && state.auth.user.team && state.auth.user.team.id
}

ImmutableJS?

The most important benefit that the ImmutableJS can give is easy reference comparision, which means, every time you make a change to a ImmutableJS Object, it will return a new reference.

So we can use ImmutableJS within our reducer:

function reducer(s = Immutable.Map({name: null}), action) {
  let state = s;

  switch (action.type ) {
    case 'CHANGE_NAME':
    state = state.set('name', action.payload.name);
  }

  return state;
}

And use setIn() in nested situations:

function reducer(s, action) {
  let state = s;

  switch (action.type ) {
    case 'CHANGE_MEMBERS_NAME':
    state = state.setIn(['members', action.payload.id, 'name' ], action.payload.name);
  }

  return state;
}

Simple and easy. For the access problem, we can use getIn():

const teamId = state.getIn(['auth', 'user', 'team', 'id']);

Looks perfect? But when you need to use the state with your UI Component there will be problems:

function mapStateToProps(state) {
  return {
    members: state.get('members'),
  };
}

function MemberList(props) {
  const list = props.members.map(member => {
    return <li key={member.get('id')}>{member.get('name')}</li>;
  });

  return <ul>{list}</ul>;
}

Basically you will end up mix ImmutableJS up with your code everywhere, and at the end, it will hard to tell if some props are ImmutableJS Object or just a plain Object, and also tied your code with ImmutableJS.

One solution will be using toJS() in every mapStateToProps to make sure all the props passed into the UI Components are pure JavaScript Object, but it is kind of anti-patterns, because:

So, in conclution, the key problem about ImmutableJS is that it transform the state into a Immutable Wrapper Object. It will leads to:

references:

How About Other Immutable Helpers

At this point, if we look back the problems we want to solve, it looks like we just need some library that can:

So there are some Immutable Helpers that I found may fullfill these requirements:

After went through all most of the modules, I found we need a module that can provide:

react-addons-update from facebook looks the most official one, but its syntax is just too tedious:

var collection = [1, 2, {a: [12, 17, 15]}];
var newCollection = update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
// => [1, 2, {a: [12, 13, 14, 15]}]

Also “react-addons-update” may be deprecated in the future

object-path-immutable‘s API is very pretty and easy to use, but it lack of structural sharing and anytime you call its API, it will just return an new reference.

sprout, good API, also with structure sharing and does not do uncessary change, but lack of Array support.

So at last, I found icepick basically has everything we need, the only problem will be lack of a API to delete nested object key, but can use another API to work around this issue easily.

Also icepick froze object, so that if you try to change state directly, it will throw an error.

How Do We Manipulate State

Though we’ve decided to use Icepick as our Immutable Helper to manipulate our state, there still need a little more guide to follow when writing your reducers, below is a simple example:

import immutable from 'icepick';

const defaultState = {
  ...
};

export default function reducer(state = defaultState, action) {
  const stateChain = immutable.chain(state);

  switch (action.type) {
    case authActionConstant.UPDATE_AUTH:
      if (action.error) {
        stateChain
          .set('user', null)
          .set('updating', false);
      } else if (action.meta.status === 'PENDING') {
        stateChain.set('updating', true);
      } else {
        stateChain
          .set('user', action.payload)
          .set('updating', false);
      }
      break;
    default:
      break;
  }
  return stateChain.value();
}

For the moment, there are only two things need to notice.

First, use ES6 default parameter syntax to give your state a default value.
After the first time the reducer is invoked (with a undefined state), it will use the default value to set itself up.

The only situation may be when you need to test your reducer, and pass state into it. And since you only want to focus on part of the state, mock a whole state will be quite tedious,
So to deal with this problem, there are two things you way want to do:

// your reducer.js
export const defaultState = { ... };

// your test case
import reducer, { defaultState } from './reducer.js';
import immutable from 'icepick';

...
it('should do ...', () => {
  const state = immutable.merge(defaultState, {
    ...
  });
  const ret = reducer(state, ...);
});

Secondly, if you need to set multiple properties at the same time, you can use chain to make your code easier:

const stateChain = immutable.chain(state);
stateChain
  .set()
  .setIn()
  .push();
const nextState = stateChain.value();

Comments

  1. Loading comments…