I've been building frontend websites with React for a while now, gaining experience with each passing year, but I recently made an assumption that cost me some hours of my life. I falsely believed that when using React context to manage state, using multiple providers in different sections of the codebase would all refer to the same state. Wild, I know.

A quick dive into React Context

React context is an in-house solution to managing state in React. It shines best when used between parents and deeply nested children, as it prevents props drilling. For example, when you pass a state required by Component C as a prop from Component A through Component B to Component C, Component B receives the state so that it can pass it to Component C without using the state itself.

Illustration of props drilling versus react context

This is how you would do it without context

import { useState } from 'react';

function ComponentA() {
    const [count, setCount] = useState(0);

    return <ComponentB count={count} />
}

// ComponentB receives state it does not use
function ComponentB({count}) {
    return <ComponentC count={count} />
}

function ComponentC({count}) {
    return <p> Count: {count} p>
}

This can get messy when you pass the props through many children components.

How to do it with context

// context.jsx
import { createContext, useState } from 'react';
export const CounterContext = createContext(undefined);

export function CounterProvider({children}) {
    const [count, setCount] = useState(0);

    return (
        <CounterContext.Provider value={{count, setCount}} >
            {children}
        CounterContext.Provider>
    )
}
import { useContext } from 'react';

function ComponentA() {
    return (
        <CounterProvider>
            <ComponentB />
        CounterProvider>
    )
}

// no longer receives state it does not use
function ComponentB() {
    return <ComponentC />
}

function ComponentC() {
    const { count } = useContext(CounterContext);

    return <p> Count: {count} p>
}

To extract and use a context value from useContext, the component must be wrapped with the context provider. The implementation in Component A resolves to

function ComponentA() {
    return (
        <CounterProvider>
            <ComponentB>
                <ComponentC>
                    <p> Count: 0 p>
                ComponentC>
            ComponentB>
        CounterProvider>
    )
}

You may be wondering why Component B, which still does not use the count state, needs to be wrapped with the provider? You would be correct. You should wrap the component (or preferably the children) at the topmost level that uses the state. In our case, we can wrap only Component C with the provider

function ComponentA() {
    return (
        <ComponentB>
            <CounterProvider>
                <ComponentC>
                    <p> Count: 0 p>
                ComponentC>
            CounterProvider>
        ComponentB>
    )
}

A common bad practice is to wrap (or its children), which is typically the topmost component, with the provider so that any child that needs the state has easy access. The problem with this is that whenever the state changes, the entire app re-renders.
Imagine you have a landing page with a countdown timer below the navbar managed by context state. With every passing second, the entire page re-renders, including the hero, the footer, and all other possibly complex sections.
The only time it's permissible to wrap is when you have two components that need the state, and you're certain that is the only common ancestor. If you get to that point, though, consider other state management libraries. However, React context is fine for small-scale state management, like theming.

The Provider divide

Remember when I said this?

The only time it's permissible to wrap is when you have two components that need the state, and you're certain that is the only common ancestor.

I sometimes wondered why I can't wrap each component separately with the provider; that way, I don't have to wrap the entire with it, and both components access the state easily.
In fact, I didn't just wonder, I did this implementation once and forgot that I did, and boy, did things go wrong.

Cue the debug session. I would update the state, but would not see the expected re-render on one of the child components. I checked the state values to ensure that it's being updated, and it is, but one child component isn't receiving these changes. Frustrating.

After a while, I finally noticed that I had the two components wrapped with separate providers, instead of wrapping a common ancestor component. The odd thing is that at that moment, I felt it was normal. It made sense. But my frustration led me to do a deep dive into the React context docs, and like a flash of lightning, I realised my mistake.

React Context Provider manages the state and allows all nested children access to the state. However, when you wrap a section with the provider and another section with the same provider, you get two different instances of the context. It's the same way we are able to reuse a component multiple times in React. Every time the component is used, a different instance is made.

Going back to my implementation, the two different components were referencing two different instances of the same context, and by extension, two different states, so they could not react to the same state change. The simple solution was to take out the providers wrapping each component and wrap the common ancestor with one provider. What a day that was.

Conclusion

Interestingly, what seemed like common sense to me at the time was more of a rookie mistake, but it was a real learning experience.
React Context is useful for light state management, and proper usage means you identify all the components that use the context state, and then wrap their common ancestor with the provider. That way, they all have access to the same instance of that state.

Thank you for your time. Let me know your thoughts on this, as well as your experience with React context. Enjoy coding.