DEV Community

loading...
Cover image for How to optimize shared states in React

How to optimize shared states in React

charbelrami profile image Charbel Rami Updated on ・5 min read

"Michelangelo was asked by the pope about the secret of his genius, particularly how he carved the statue of David, largely considered the masterpiece of all masterpieces. His answer was: It’s simple. I just remove everything that is not David." - Nassim Nicholas Taleb in Antifragile

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
export const Context = createContext();
Enter fullscreen mode Exit fullscreen mode
export function Counter() {
  const { count } = useContext(Context);

  return <span>{count}</span>;
}
Enter fullscreen mode Exit fullscreen mode
export function Increment() {
  const { setCount } = useContext(Context);

  return <button onClick={() => setCount(prev => prev + 1)}>Increment</button>;
}
Enter fullscreen mode Exit fullscreen mode
export function Decrement() {
  const { setCount } = useContext(Context);

  return <button onClick={() => setCount(prev => prev - 1)}>Decrement</button>;
}
Enter fullscreen mode Exit fullscreen mode
export function Toggle() {
  const { toggle, setToggle } = useContext(Context);

  return (
    <label>
      <input
        type="checkbox"
        checked={toggle}
        onChange={() => setToggle(prev => !prev)}
      />
      Toggle
    </label>
  );
}
Enter fullscreen mode Exit fullscreen mode

codesandbox

This image shows that we clicked the increment button

(During the profiling sessions, the increment button was clicked)

This image shows that all the components rendered during the profiling session

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

codesandbox

This image shows that the subtree didn’t render during the profiling session

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
export const CountContext = createContext();
Enter fullscreen mode Exit fullscreen mode
export const ToggleContext = createContext();
Enter fullscreen mode Exit fullscreen mode

codesandbox

This image shows that all the consumers rendered during the profiling session

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
export function CountProvider({ children }) {
  const [count, setCount] = useState(0);

  return (
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode
export function ToggleProvider({ children }) {
  const [toggle, setToggle] = useState(false);

  return (
    <ToggleContext.Provider value={{ toggle, setToggle }}>
      {children}
    </ToggleContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

codesandbox

This image shows that only the count consumers rendered during the profiling session

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
export function ToggleProvider({ children }) {
  const [toggle, setToggle] = useState(false);

  return (
    <ToggleContext.Provider value={toggle}>
      <SetToggleContext.Provider value={setToggle}>
        {children}
      </SetToggleContext.Provider>
    </ToggleContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

codesandbox

This image shows that only one consumer rendered during the profiling session

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
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode
export function Increment() {
  const setCount = useSetStateContext(contexts, "count");

  return <button onClick={() => setCount(prev => prev + 1)}>Increment</button>;
}
Enter fullscreen mode Exit fullscreen mode
export function Decrement() {
  const setCount = useSetStateContext(contexts, "count");

  return <button onClick={() => setCount(prev => prev - 1)}>Decrement</button>;
}
Enter fullscreen mode Exit fullscreen mode
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

codesandbox

This image shows that only one consumer rendered during the profiling session using context-x

Thanks!

Learn more:

Discussion (0)

pic
Editor guide