1 July 2019

cover

React hooks useState and useReducer are equivalent in theoretical expressiveness

Which do you prefer?
Share on:

Introduction

useReducer is a powerful hook. It’s known that useState is implemented with useReducer.

In the React hooks docs, it’s noted like this:

useReducer is usually preferable to useState when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one. useReducer also lets you optimize performance for components that trigger deep updates because you can pass dispatch down instead of callbacks.

For a long time, I misunderstood that useReducer is more powerful than useState and there’s some optimization that can’t be achieved by useState.

It turns out that useState is as powerful as useReducder in terms of expressiveness. This is because useState allows functional updates. Even with deep updates, you can pass down custom callbacks.

So, whether you useState or useReducer is just your preference. I would useReducer when I used dispatch in JSX. Having logic outside of JSX seems clean to me.

Example

If you create a custom hook, whether you useState or useReducer is just about an internal implementation issue. Let’s look at an example. We implement a simple counter example with two hooks. For both cases, hooks return action callbacks, which is important to hide implementation details in this comparison.

useReducer

const initialState = { count1: 0, count2: 0 };

const reducer = (state, action) => {
  switch (action.type) {
    case 'setCount1':
      if (state.count1 === action.value) return state; // to bail out
      return { ...state, count1: action.value };
    case 'setCount2':
      if (state.count2 === action.value) return state; // to bail out
      return { ...state, count2: action.value };
    default:
      throw new Error('unknown action type');
  }
};

const useCounter = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const setCount1 = useCallback(value => {
    dispatch({ type: 'setCount1', value });
  }, []);
  const setCount2 = useCallback(value => {
    dispatch({ type: 'setCount2', value });
  }, []);
  return { ...state, setCount1, setCount2 };
};

useState

const initialState = { count1: 0, count2: 0 };

const useCounter = () => {
  const [state, setState] = useState(initialState);
  const setCount1 = useCallback(value => {
    setState(prevState => {
      if (prevState.count1 === value) return prevState; // to bail out
      return { ...state, count1: value };
    });
  }, []);
  const setCount2 = useCallback(value => {
    setState(prevState => {
      if (prevState.count2 === value) return prevState; // to bail out
      return { ...state, count2: value };
    });
  }, []);
  return { ...state, setCount1, setCount2 };
};

Which do you feel comfortable with?

Bonus

If useState is as powerful as useReducer, useReducer should be able to be implemented with useState in userland.

const useReducer = (reducer, initialArg, init) => {
  const [state, setState] = useState(
    init ? () => init(initialArg) : initialArg,
  );
  const dispatch = useCallback(
    action => setState(prev => reducer(prev, action)),
    [reducer],
  );
  return useMemo(() => [state, dispatch], [state, dispatch]);
};

(Note: This doesn’t support the cheat mode.)

Final notes

Most of my libraries are written with useReducer, but I might change my mind a bit, and consider using useState when it’s more appropriate.

I did change my experimental library. The diff is here.

https://github.com/dai-shi/react-hooks-fetch

One last note, as for unit testing, I’m sure separated reducer is easier to test.

comments powered by Disqus