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>
  );
}
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>
  );
}

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 be inclined to 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>
  );
}

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

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>
  );
}
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>
  );
}

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

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

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 (2kB) 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>
  );
}

codesandbox

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

Thanks!

Learn more:

Discussion

markdown guide