DEV Community

Samuel Rouse
Samuel Rouse

Posted on

Stable State: Reducing React's Relentless Rendering

React hooks removed a lot of code complexity, yet they introduce new challenges that can cause unexpected render cycles and difficult to diagnose defects as application complexity increases.

Render Repetition

React is designed to render many times and only update the page when necessary. This is intentional, but it means your code may run more frequently than expected. There is a difference between when a component is rendered and the version you see, but we'll get to that. First, I wan to quibble about terminology.

React Redefines "Render"?

Each run of a function React component is a "render", but not all of these function calls cause a display update, which can be confusing. The term "render" is used because it is creating DOM nodes, a bit like offscreen rendering in graphics. In React, "render" means a function call, and "update", "commit", or "paint" are used for placing content on the DOM.

Hooks

Before hooks, Pure Functional Components were used for simple, stateless components. These could be safely run many times without risk.

const ListItem = ({ id, label }) => (<li key={id}>{label}</li>);
Enter fullscreen mode Exit fullscreen mode

Hooks hide the overhead of state management – similar to the instances Class Components used to be explicitly create – but they don't change the nature of React. It still calls components more often than the screen updates might suggest.

The Hook Brings You Back

With useState, setting a new value will cause the component to be called again. Setting the same value, however, only might trigger a call. The useState Reference Docs mention the result may be ignored, but there are still sometimes "extra" calls.

These seem to happen if any "conditions" – pieces of component state – are different from the last action. After changing any state value, the next set action will cause the component to render, even if the action and outcome are the same.

Let's see the calls and display cycles in action in the example below. You can set the object to [0], or to an array containing the primitive state value, represented by the [x] button.

Notice repeatedly setting the same primitive does not keep incrementing the the callCount – beyond the "conditions changed" call, anyway. In contrast, every set of the object causes a call and an update. This is because the object value is a different object each time, while the primitive value is strictly equal.

In the example, the callCount used to provide the Display Cycle and Call Count is tracked outside the component, but useRef can provide the same experience if you prefer.

Noise is Nothing New

Though none of this should be shocking, it might not be well known by everyone. Even the React Tutorial recommends creating new objects in state but doesn't talk about equivalence. We might assume state is only set when it changes, which makes sense in an example or simple application, but there are reasons you might get repetitive or identical values, from API calls to timers.

As we add effects, requests, and complexity, we can encounter defects that can be difficult to trace.

Maybe Memo Matters

Built-in methods like memo can help avoid extra calls, but even the docs caution against relying on it:

This memoized version of your component will usually not be re-rendered when its parent component is re-rendered as long as its props have not changed. But React may still re-render it: memoization is a performance optimization, not a guarantee.

Stable State Storage

We can try to prevent unnecessary updates, which is what we've done at my job for our simple state objects.

We created a small hook to ensure that state objects are not updated unnecessarily. This is done with a comparator or a function that compares two values to see if they are equivalent. We use react-fast-compare for this, though our hook accepts alternatives. We call it useStableState.

useStableState

import isEqual from 'react-fast-compare';

const useStableState = (initial, comparator = isEqual) => {
  const [state, setState] = useState(initial);
  const setStable = useCallback((next) => {
    const nextFunction = typeof next === "function";
    setState((prev) => {
      const update = nextFunction ? next(prev) : next;
      return comparator(prev, update) ? prev : update;
    });
  }, [comparator]);
  return [state, setStable];
};
Enter fullscreen mode Exit fullscreen mode

This hook has three key principles:

  1. Match the useState design and support a value or function argument.
  2. Ensure we don't keep recreate the "setter" function with useCallback.
  3. Return the previous state, not the new one, when the comparator returns true.

That last point is crucial to how the hook works. If the objects are considered to be the same, returning the existing state object avoids a state update and an unnecessary call.

Try it yourself.

Other Object Options

We encountered similar problems when accessing objects from Redux, and implemented a hook called useComputedSelector. While useSelector already supports an equality function (comparator), we found it doesn't always respect the result.

Extracting individual primitive values with multiple useSelector hooks is another solution, but can significantly impact the code readability when dealing with large objects. Additionally there can be performance considerations in limited cases. Every useSelector is called when the Redux state changes because they are triggered by the Context provider update.

useComputedSelector

const useComputedSelector = (selectorFn, equalityFn = isEqual) => {
  const previousValue = useRef();
  return useSelector((state) => {
    const newValue = selectorFn(state);
    if (!equalityFn(previousValue.current, newValue)) {
      previousValue.current = newValue;
    }
    return previousValue.current;
  });
};
Enter fullscreen mode Exit fullscreen mode

Asking About Alternatives

We created these simple solutions to reduce unwanted renders in a complex application with many layers of nested state, but I'm interested to hear the changes, proposals, and solutions you use to prevent unnecessary state updates and reduce the amount of work React has to do. Thanks for reading!

Top comments (0)