How to optimize shared states in React
Charbel Rami
Posted on March 10, 2020
Consider the following example:
export default function App() {
const [count, setCount] = useState(0);
const [toggle, setToggle] = useState(false);
return (
<Context.Provider value={{ count, setCount, toggle, setToggle }}>
<SubtreeComponent>
<Decrement />
<Counter />
<Increment />
<Toggle />
</SubtreeComponent>
</Context.Provider>
);
}
export const Context = createContext();
export function Counter() {
const { count } = useContext(Context);
return <span>{count}</span>;
}
export function Increment() {
const { setCount } = useContext(Context);
return <button onClick={() => setCount(prev => prev + 1)}>Increment</button>;
}
export function Decrement() {
const { setCount } = useContext(Context);
return <button onClick={() => setCount(prev => prev - 1)}>Decrement</button>;
}
export function Toggle() {
const { toggle, setToggle } = useContext(Context);
return (
<label>
<input
type="checkbox"
checked={toggle}
onChange={() => setToggle(prev => !prev)}
/>
Toggle
</label>
);
}
(During the profiling sessions, the increment
button was clicked)
Intuitively, when we change a context value, we might assume that this change propagates solely to context consumers (components calling useContext
) that use this particular value. However, a change in a single value of a context propagates to all its consumers scheduling them to update and re-render regardless of whether they use this value or not. This change also causes the entire subtree wrapped in the context provider to re-render.
Although it may not necessarily result in significant performance issues, except when values change too often or when there are expensive re-render calculations that haven’t been memoized (useMemo
), it is more likely to lead to undesirable behavior, particularly when a consumer component fires effects after every render.
Firstly, we want to prevent the context provider subtree from re-rendering unnecessarily. This can be accomplished by passing the provider subtree as a children
prop to a wrapper component.
(The context provider subtree is represented by SubtreeComponent
for the sake of simplicity)
export default function App() {
return (
<Provider>
<SubtreeComponent>
<Decrement />
<Counter />
<Increment />
<Toggle />
</SubtreeComponent>
</Provider>
);
}
export function Provider({ children }) {
const [count, setCount] = useState(0);
const [toggle, setToggle] = useState(false);
return (
<Context.Provider value={{ count, setCount, toggle, setToggle }}>
{children}
</Context.Provider>
);
}
Now, we want to prevent consumers from re-rendering unless necessary, or, more precisely, unless they actually use the changed value. One convenient approach is to create a separate context for each independent value.
export function Provider({ children }) {
const [count, setCount] = useState(0);
const [toggle, setToggle] = useState(false);
return (
<CountContext.Provider value={{ count, setCount }}>
<ToggleContext.Provider value={{ toggle, setToggle }}>
{children}
</ToggleContext.Provider>
</CountContext.Provider>
);
}
export const CountContext = createContext();
export const ToggleContext = createContext();
Note that the consumers rendered nonetheless. This happens because both state variable declarations are in the same parent component. So we should split them into two components.
export default function App() {
return (
<CountProvider>
<ToggleProvider>
<SubtreeComponent>
<Decrement />
<Counter />
<Increment />
<Toggle />
</SubtreeComponent>
</ToggleProvider>
</CountProvider>
);
}
export function CountProvider({ children }) {
const [count, setCount] = useState(0);
return (
<CountContext.Provider value={{ count, setCount }}>
{children}
</CountContext.Provider>
);
}
export function ToggleProvider({ children }) {
const [toggle, setToggle] = useState(false);
return (
<ToggleContext.Provider value={{ toggle, setToggle }}>
{children}
</ToggleContext.Provider>
);
}
State variable declarations return a pair of values, the current state and a function that updates that state. These values can be consumed independently, so we should split them into two contexts.
export function CountProvider({ children }) {
const [count, setCount] = useState(0);
return (
<CountContext.Provider value={count}>
<SetCountContext.Provider value={setCount}>
{children}
</SetCountContext.Provider>
</CountContext.Provider>
);
}
export function ToggleProvider({ children }) {
const [toggle, setToggle] = useState(false);
return (
<ToggleContext.Provider value={toggle}>
<SetToggleContext.Provider value={setToggle}>
{children}
</SetToggleContext.Provider>
</ToggleContext.Provider>
);
}
So far, so good. But as you may have noticed, this code could rapidly become too long and time-consuming.
react-context-x is a tiny (3kB) library that might come in handy. It provides a familiar API that is basically an abstraction to the code shown in these examples.
Consider an object of all the states we want to share from the same level in the component tree.
const states = {
count: 0,
toggle: false
};
createContexts
(plural) is a function that receives these states, creates a pair of contexts for each of one, and returns an array with all these pairs.
const states = {
count: 0,
toggle: false
};
export const contexts = createContexts(states);
Then, we pass this array to a Providers
component that inserts all the required providers into the component tree.
export default function App() {
return (
<Providers contexts={contexts}>
<SubtreeComponent>
<Decrement />
<Counter />
<Increment />
<Toggle />
</SubtreeComponent>
</Providers>
);
}
To consume these contexts, we use hooks that accept the array as the first argument and, as the second argument, a string that identifies which context we want to access.
export function Counter() {
const count = useStateContext(contexts, "count");
return <span>{count}</span>;
}
export function Increment() {
const setCount = useSetStateContext(contexts, "count");
return <button onClick={() => setCount(prev => prev + 1)}>Increment</button>;
}
export function Decrement() {
const setCount = useSetStateContext(contexts, "count");
return <button onClick={() => setCount(prev => prev - 1)}>Decrement</button>;
}
export function Toggle() {
const toggle = useStateContext(contexts, "toggle");
const setToggle = useSetStateContext(contexts, "toggle");
return (
<label>
<input
type="checkbox"
checked={toggle}
onChange={() => setToggle(prev => !prev)}
/>
Toggle
</label>
);
}
Thanks!
Learn more:
Posted on March 10, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.