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:
- examines a typical pattern for sharing state using the Context API, and
- discusses performance implications of the above pattern, and proposes a simple modification using the
useRefanduseCallbackhooks 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: setsvalueto a random number when clicked,LogValueButton: logs the currentvaluein the console when clicked, andValueDisplay: displays the currentvalue.
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 whensetStateis called], this Hook will trigger a rerender with the latest contextvaluepassed to thatMyContextprovider.
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:
useRefreturns a mutable ref object whose.currentproperty 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.
Chris Palmieri