@Chamion personal blog

How Redux triggers component updates

Back when hooks were a new addition to React my team found it difficult to migrate certain packages because new versions would conflict with our old code. One of these difficult packages was react-redux. We couldn't utilize useSelector or useDispatch but we didn't want to perpetuate the old patterns of Redux code because we knew we wanted to migrate eventually.

My solution was to write our own poor man's implementations of the hooks which we could then replace with the real deal when we make the jump. This task had me looking into the internal workings of Redux and it turned out to be wildly interesting. Redux uses certain quirks of primitive hooks in ways that are eccentric, to put it politely. "Disgusting" was the word I used at the time but later on I've grown to appreciate the tricks used and I think all React developers could learn from it.

The problem with useContext

Pop quiz, when does useContext trigger a component update?

Reveal answer Hide answer

Whenever the referential identity of the context value changes.

Okay, next question. When does useSelector trigger a component update?

Reveal answer Hide answer

Whenever the selected value changes. Change is defined as not equalling the previous value as defined by an equality function passed as an argument or by default identity equality (===).

Putting these two together we face a paradox. useSelector invokes useContext to access the Redux context that provides access to the state. How can the hook use the context that has the whole state in it without triggering updates for every change in that state?

The solution to useContext

Redux stops the unnecessary updates from useContext by stopping all updates from it. useContext only ever updates when the identity of its value changes. We can mutate the value without causing an update. You're never supposed to mutate state in React. We learned that ten minutes into the Indian tutorial video we learned React from. But when you know React as well as Dan does, you can break the rules.

So let's start building our own poor man's Redux for demonstration purposes.

const ReduxProvider = ({ initialState, reducer, children }) => {
  const state = useRef(initialState);
  const getState = useCallback(() => state.current, []);

  const dispatch = useCallback(
    action => void (state.current = reducer(state, action)),
    [reducer]
  );

  const [store] = useState(() => ({
    getState,
    dispatch
  }));

  return (
    <Context.Provider value={store}>
      {children}
    </Context.Provider>
  );
};

Subscribers and publishers

What I just wrote down indeed causes no unnecessary rerenders from useContext. Unfortunately it doesn't cause any updates. We'll need to manually stay up-to-date with the context value now that it doesn't trigger updates anymore.

We'll need to add some callbacks. Let's add a subscribe callback that allows registering subscribers to the context. Dispatching an action should then publish the changes to all subscribers.

const ReduxProvider = ({ initialState, reducer, children }) => {
  const state = useRef(initialState);
  const getState = useCallback(() => state.current, []);

  const subscribers = [];
  const subscribe = useCallback(subscriber => subscribers.current.push(subscriber));
  const unsubscribe = useCallback(subscriber => {
    const index = subscribers.indexOf(subscriber);
    if (index !== -1) subscribers.splice(index, 1);
  });

  const dispatch = useCallback(
    action => void (
      state.current = reducer(state, action),
      subscribers.current.forEach(subscriber => subscriber(state.current))
    ),
    [reducer]
  );

  const [store] = useState(() => ({
    getState,
    dispatch,
    subscribe,
    unsubscribe

  }));

  return (
    <Context.Provider value={store}>
      {children}
    </Context.Provider>
  );
};

In a subscriber we can conditionally update our selected value as a response to the state changing.

const useSelector = (selector, equalityFn = refEquals) => {
  const { getState, subscribe, unsubscribe } = useContext(Context);

  const [selectedValue, setSelectedValue] = useState(() => selector(getState()));

  const subscriber = useCallback(
    state => {
      const newValue = selector(getState());
      setSelectedValue(
        prevValue => equalityFn(prevValue, newValue) ? prevValue : newValue
      );
    },
    [selector, equalityFn, getState]
  );

  useEffect(() => (
    subscribe(subscriber),
    () => unsubscribe(subscriber)
  ), [subscribe, unsubscribe, subscriber]);

  return selectedValue;
};

const refEquals = (a, b) => a === b;

Learnings

We've written bootleg Redux! It doesn't handle corner cases, it's full of bugs and the API is different from Redux but it does handle the basic use case of selecting state values and dispatching actions. Component's won't rerender needlessly when state changes due to the hacks we performed with mutation.

Redux hooks take a brave approach to solving the performance issues of having lots of state in one context. It works because all the weird logic showcased in this post is hidden from the user. A developer using Redux needs not know of all the curios in the background. If you write something similar in your own code make sure you test it vigorously and encapsulate all the weirdness because your coworkers will have words for you if they need to touch any of it.

I hope this examination gave you an appreciation for creative ways to improve React app performance. It's not always about useMemo and referential identities.