Push or Pull State with React Context (useContext, useState, useRef, useCallback)

tldr: this CodeSandbox demonstrates a way to fetch state via a context only when required, eliminating unnecessary renders.

Overview

The React Context API has made accessing state anywhere in the component tree (and writing clean code without prop drilling) easier than ever before.

This article:

  1. examines a typical pattern for sharing state using the Context API, and
  2. discusses performance implications of the above pattern, and proposes a simple modification using the useRef and useCallback hooks to eliminates unnecessary renders.

The basic pattern

The basic pattern for making some value and the corresponding setValue function globally available is based around the below ValueProvider:

const ValueContext = createContext<number | null>(null);
const SetValueContext = createContext<Dispatch<SetStateAction<number>> | null>(
  null
);

const ValueProvider = ({ children }: { children: React.ReactNode }) => {
  const INITIAL_VALUE = 0;
  const [value, setValue] = useState<number>(INITIAL_VALUE);

  return (
    <ValueContext.Provider value={value}>
      <SetValueContext.Provider value={setValue}>
        {children}
      </SetValueContext.Provider>
    </ValueContext.Provider>
  );
};

For the first usage example, we define 3 components that consume the context:

  • SetValueButton: sets value to a random number when clicked,
  • LogValueButton: logs the current value in the console when clicked, and
  • ValueDisplay: displays the current value.
const SetValueButton = () => {
  const setValue = useContext(SetValueContext);
  return (
    <ButtonWithRenderCount
      text="set random value"
      onClick={() => setValue && setValue(Math.round(Math.random() * 100))}
    />
  );
};

const LogValueButton = () => {
  const value = useContext(ValueContext);
  return (
    <ButtonWithRenderCount
      text="console.log(value)"
      onClick={() => console.log(value)}
    />
  );
};

const ValueDisplay = () => {
  const value = useContext(ValueContext);
  return <div>value: {value}</div>;
};

export const App = () => (
  <ValueProvider>
    <SetValueButton />
    <LogValueButton />
    <ValueDisplay />
  </ValueProvider>
);

Note the buttons are wrapped in ButtonsWithRenderCount, which (just for the purpose of the below demo) displays the render count adjacent the button.

const ButtonWithRenderCount = ({
  text,
  onClick
}: {
  text: string;
  onClick: () => void;
}) => {
  const renderCountRef = useRef(0);
  renderCountRef.current += 1;

  return (
    <div>
      <button onClick={onClick}>{text}</button>
      <span>renders: {renderCountRef.current}</span>
    </div>
  );
};

Demo

Test out each button in the below demo. The buttons behave as expected, but notice that the LogValueButton component renders every time value changes, even though there are no UI changes 🤔 :



Problems with this pattern

The React API doc for useContext sheds light on why these unwanted renders occur:

When the nearest <MyContext.Provider> above the component updates [i.e when setState is called], this Hook will trigger a rerender with the latest context value passed to that MyContext provider.

Performance implications

Imagine a use case where value changes frequently (i.e tracking cursor position, the scroll position of a container, or perhaps the completion progress of a background process). Now additionally imagine there are a large number of these LogValueButtons, say, on each line in a list of search results.

React renders are not free (even if the final result is “no changes, do not update the DOM”), so an app using this pattern in this type of scenario would suffer from performance issues.

The improved pattern

The LogValueButton doesn’t need to always know the current value – only where to get it when clicked. We could say the above pattern causes the LogValueButton to “subscribe” to the state (the “Push” pattern), while a more efficient pattern would allow it to “get” the state when required (the “Pull” pattern).

To enable an option to consume value via the “Pull” pattern, ValueProvider can be updated as follows:

  const ValueContext = createContext<number | null>(null);
+ const ValueRefContext = createContext<MutableRefObject<number> | null>(null);
  const SetValueContext = createContext<Dispatch<SetStateAction<number>> | null>(
    null
  );

  const ValueProvider = ({ children }: { children: React.ReactNode }) => {
    const INITIAL_VALUE = 0;
    const [value, setValue] = useState<number>(INITIAL_VALUE);
+   const valueRef = useRef<number>(INITIAL_VALUE);
+   valueRef.current = value;

    return (
      <ValueContext.Provider value={value}>
+       <ValueRefContext.Provider value={valueRef}>
          <SetValueContext.Provider value={setValue}>
            {children}
          </SetValueContext.Provider>
+       </ValueRefContext.Provider>
      </ValueContext.Provider>
    );
  };

How it works

React Docs discuss the most common use for refs (tl;dr – refs allow direct access to DOM nodes), but the useRef API Doc reveals the property that makes this hook very powerful for solving a broader class of problems:

useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.

In other words, while the .current property of the valueRef changes frequently, the valueRef object is stable for the lifetime of the provider (note that “lifetime” does not mean “a render cycle” – it means until the ValueProvider is unmounted, in which case all child consumer components are also unmounted).

Because of this, we can define a custom hook useGetValue, and sleep soundly trusting it returns a stable callback that returns an object with the .current property always pointing to the latest value:

const useGetValue = () => {
  const valueRef = useContext(ValueRefContext);
  return useCallback(() => valueRef?.current, [valueRef]);
};

To test out the new hook, we add an additional button, LogGetValueButton, which behaves in exactly the same way as the LogValueButton but only renders once 🎉🥳 :

const LogGetValueButton = () => {
  const getValue = useGetValue();
  return (
    <ButtonWithRenderCount
      text="console.log(getValue())"
      onClick={() => console.log(getValue())}
    />
  );
};

After dropping it in alongside the other components…

 export const App = () => (
    <ValueProvider>
      <SetValueButton />
      <LogValueButton />
+     <LogGetValueButton />
      <ValueDisplay />
    </ValueProvider>
 );

…let’s test it out!

Notice that while the LogValueButton continues to render every time value changes, LogGetValueButton only renders once.

Questions?

Do you have any questions or feedback? Feel free to leave a comment below, or to reach out to me directly.