DEV Community

Cover image for React Context, All in One
Javier Murillo
Javier Murillo

Posted on • Edited on

React Context, All in One

All you need to know about the React Context API: basics, optimization, good practices, testing, and future. All the pieces together. All in One.

What is React Context for?

✔️ Simple dependency injection mechanism, avoiding the infamous prop drilling.
✔️ No third-party libraries, React Context is integrated with React and for sure this API will be updated in the future with many improvements.
✔️ Ideal when you can split your states in order to make them accesible to your React component tree (e.g. Theme, Authentication, i18n, ...)
❌ It is not a global state management tool. You manage your state via useState or useReducer.
❌ If you app state is frequently updated Context is not the best solution.
❌ Not suitable if you need complex capabilities such as side effects, persistence and data serialization.
❌ Worse debugging since you don't have "Redux DevTools" including the actions history for example.
❌ You have to implement it right in order to avoid optimization leaks. React does not help you there. This post does.

React Context usage example

Let's start straight with some code in order to know:

  1. How to create a Context.
  2. How to create a Provider which will provide the context value.
  3. How to create Consumer components which will use the context value.
// index.jsx
ReactDOM.render(
  <MyProvider>
    <MyEntireApp/>
  </MyProvider>,
  document.getElementById('root'),
)
Enter fullscreen mode Exit fullscreen mode
// myContext.js
import { createContext } from 'react'

// Creating the Context
const MyContext = createContext()

export default MyContext
Enter fullscreen mode Exit fullscreen mode
// MyProvider.jsx
const MyProvider = ({ children }) => {
  const [state, setState] = useState({})

  const fetch = async () => {
    // Fetching some data
    setState({ ... })
 }

  useEffect(() => {
    fetch()
  }, [])

  // Providing a value
  return (
     <MyContext.Provider value={{state, setState}}>
       {children}
     </MyContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode
// FunctionalComponent.jsx
const Consumer = () => {
  // Consuming the Context
  const myContext = useContext(MyContext)

  return (
    // Here we can access to the context state
  )
}
Enter fullscreen mode Exit fullscreen mode
// ClassComponent.jsx
class Consumer {
  constructor () { ... }

  render () {
    // Consuming the Context
    <MyContext.Consumer>
      {(myContext) => (
        // Here we can access to the context state
      )}
    </MyContext.Consumer>
  }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ When the nearest <MyContext.Provider> above the component updates, React.useContext(...) will trigger a rerender with the latest context value passed to that MyContext provider. Even if an ancestor uses React.memo or shouldComponentUpdate, a rerender will still happen starting at the component itself using useContext.

A component calling useContext will always re-render when the context value changes. If re-rendering the component is expensive, you can optimize it by using memoization.

https://reactjs.org/docs/hooks-reference.html#usecontext

What happens with the initial value passed to React.createContext(...)?

In our example above we are passing undefined as a our initial context value, but at the same time we are overriding it in our Provider:

const MyContext = createContext()
Enter fullscreen mode Exit fullscreen mode
<MyContext.Provider value={{state, setState}}>
  {children}
</MyContext.Provider>
Enter fullscreen mode Exit fullscreen mode

The value that createContext is receiving as default (undefined) will be the one a Consumer will receive if it does not have any Provider above itself in the component tree.

const Root = () => {
  // ⚠️ Here we will get an error since we cannot
  // destructure `state` from `undefined`.
  const { state } = useContext(MyContext)
  return <div>{state}</div>
}
ReactDOM.render(<Root />, document.getElementById('root'))
Enter fullscreen mode Exit fullscreen mode

In our case, our Consumers will always have a Provider above them, since our Provider wraps the entire application (see index.js). The implementation of a custom hook to use our Context could be a cool idea in order to improve code legibility, abstract the use of useContext, and throw an error if our Context is used incorrectly (remember, failing fast).

// MyProvider.jsx
const MyProvider = ({ children }) => {
  const [state, setState] = useState([])

  // Provider stuff...

  <MyContext.Provider value={{state, setState}}>
    {children}
  </MyContext.Provider>
}

// For Hooks
const useMyCtx = () => {
  const context = useContext(MyContext)
  if (context === undefined) {
    throw new Error('useMyCtx must be used withing a Provider')
  }
  return context
}

// For Classes
const ContextConsumer = ({ children }) => {
  return (
    <MyContext.Consumer>
      {context => {
        if (context === undefined) {
          throw new Error('ContextConsumer must be used 
            within a Provider')
        }
        return children(context)
      }}
    </MyContext.Consumer>
  )
}

export { MyProvider, useMyCtx, ContextConsumer }
Enter fullscreen mode Exit fullscreen mode

With Hooks

// FunctionalComponent.jsx
const Consumer = () => {
  const context = useMyCtx()
}
Enter fullscreen mode Exit fullscreen mode

With Classes

// ClassComponent.jsx
class Consumer extends Component {
  constructor() { ... }

  render() {
    return <ContextConsumer>
      {context => // Here we can access to the context state }
      </ContextConsumer>
  }
}
Enter fullscreen mode Exit fullscreen mode

Does my entire app re-render if the Provider state changes?

Depends on how you implemented your provider:

// ❌ Bad
// When the provider's state changes, React translates the rendering
// of <MyEntireApp/> as follows:
// React.creatElement(MyEntireApp, ...),
// rendering it as a new reference.
// ⚠️ No two values of the provider’s children will ever be equal,
// so the children will be re-rendered on each state change.
const Root = () => {
  const [state, setState] = useState()

  <MyContext.Provider value={{state, setState}>
    <MyEntireApp />
  </MyContext.Provider>
}
Enter fullscreen mode Exit fullscreen mode
// ✔️ Good
// When the provider's state changes, the children prop
// stays the same so <MyEntireApp/> is not re-rendering.
// `children` prop share reference equality with its previous
// `children` prop.
const MyProvider = ({ children }) => {
  const [state, setState] = useState()

  <MyContext.Provider value={{state, setState}}>
    {children}
  </MyContext.Provider>
}

const Root = () => {
  <MyProvider>
    <MyEntireApp />
  </MyProvider>
}
Enter fullscreen mode Exit fullscreen mode

Can I store my global state in just one Context?

No. Well, yes, but you shouldn't. The reason is simple, consider the following global state:

{
  auth: {...}
  translations: {...}
  theme: {...}
}
Enter fullscreen mode Exit fullscreen mode

⚠️ If a component only consumes the theme, it still will be re-rendered even if another state property changes.

// FunctionalComponent.jsx
// This component will be re-rendered when `MyContext`'s
// value changes, even if it is not the `theme`.
const Consumer = () => {
  const { theme } = useContext(MyContext)

  render <ExpensiveTree theme={theme} />
}
Enter fullscreen mode Exit fullscreen mode

You should instead split that state in some Contexts. Something like this:

// index.jsx
// ❌ Bad
ReactDOM.render(
  <GlobalProvider>
     <MyEntireApp/>
  </GlobalProvider>,
  document.getElementById('root'),
)

// ✔️ Good
ReactDOM.render(
  <AuthProvider>
    <TranslationsProvider>
      <ThemeProvider>
        <MyEntireApp/>
      </ThemeProvider>
    </TranslationsProvider>
  </AuthProvider>,
  document.getElementById('root'),
)
Enter fullscreen mode Exit fullscreen mode

As you can see this can end in an endless arrowhead component, so a good practice could be splitting this in two files:

// ProvidersWrapper.jsx
// This `ProvidersWrapper.jsx` can help you implementing testing 
// at the same time.
const ProvidersWrapper = ({ children }) => (
  <AuthProvider>
    <TranslationsProvider>
      <ThemeProvider>
        {children}
      </ThemeProvider>
    </TranslationsProvider>
  </AuthProvider>
)
Enter fullscreen mode Exit fullscreen mode
// index.jsx
ReactDOM.render(
  <ProvidersWrapper>
    <MyEntireApp/>
  </ProvidersWrapper>,
  document.getElementById('root'),
)
Enter fullscreen mode Exit fullscreen mode

By doing this, each Consumer should use just what it needs.

Alternatives to splitting Contexts

Instead of splitting contexts, we could apply the following techniques in order to <ExpensiveTree /> don't re-render if a property he is not consuming changes:

1. Spliting the Consumer in two with memo in between.

// FunctionalComponent.jsx
const Consumer = () => {
  const { theme } = useContext(MyContext)

  return <ThemeConsumer theme={theme} />
}

const ThemeConsumer = memo(({ theme }) => {
  // The rest of your rendering logic
  return <ExpensiveTree theme={theme} />
})
Enter fullscreen mode Exit fullscreen mode

An advanced implmentation would be the creation of a HOC with a custom connect(...) function as follows:

const connect = (MyComponent, select) => {
  return function (props) {
    const selectors = select();
    return <WrappedComponent {...selectors} {...props}/>
  }
}
Enter fullscreen mode Exit fullscreen mode
import connect from 'path/to/connect'

const MyComponent = React.memo(({
    somePropFromContext,
    otherPropFromContext, 
    someRegularPropNotFromContext  
}) => {
    ... // regular component logic
    return(
        ... // regular component return
    )
});

const select = () => {
  const { someSelector, otherSelector } = useContext(MyContext);
  return {
    somePropFromContext: someSelector,
    otherPropFromContext: otherSelector,
  }
}

export default connect(MyComponent, select)
Enter fullscreen mode Exit fullscreen mode

Source: https://github.com/reactjs/rfcs/pull/119#issuecomment-547608494

However this is against the nature of React Context and does not solve the main issue: the HOC what wraps the Component still tries to re-render, there may be multiple HOCs for just one updated resulting in an expensive operation.

2. One component with useMemo inside

const Consumer = () => {
  const { theme } = useContext(MyContext)

  return useMemo(() => {
    // The rest of your rendering logic
    return <ExpensiveTree theme={theme} />
  }, [theme])
}
Enter fullscreen mode Exit fullscreen mode

3. Third-party React Tracked

Prior to v1.6.0, React Tracked is a library to replace React Context use cases for global state. React hook useContext triggers re-renders whenever a small part of state object is changed, and it would cause performance issues pretty easily. React Tracked provides an API that is very similar to useContext-style global state.

const useValue = () => useState({
  count: 0,
  text: 'hello',
})

const { Provider, useTracked } = createContainer(useValue)

const Consumer = () => {
  const [state, setState] = useTracked()
  const increment = () => {
    setState((prev) => ({
      ...prev,
      count: prev.count + 1,
    })
  }
  return (
    <div>
      <span>Count: {state.count}</span>
      <button type="button" onClick={increment}>+1</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The useTracked hook returns a tuple that useValue returns, except that the first is the state wrapped by proxies and the second part is a wrapped function for a reason.

Thanks to proxies, the property access in render is tracked and this component will re-render only if state.count is changed.

https://github.com/dai-shi/react-tracked

Do I need to memoize my Provider value or my component?

It depends. Apart from the cases we just saw... Do you have a parent above your Provider which can be updated forcing a natural children re-rendering by React?

// ⚠️ If Parent can be updated (via setState() or even via
// a grandparent) we must be careful since everything
// will be re-rendered.
const Parent = () => {
  const [state, setState] = useState()

  // Stuff that forces a re-rendering...

  return (
    <Parent>
      <MyProvider>
        <MyEntireApp/>
      </MyProvider>
    </Parent>
  )
}
Enter fullscreen mode Exit fullscreen mode

If so, yes. You will have to memoize both the Provider and your Component as follows:

// MyProvider.jsx
const MyProvider = ({ children }) => {
  const [state, setState] = useState({})

  // With `useMemo` we avoid the creation of a new object reference
  const value = useMemo(
    () => ({
      state,
      setState,
    }),
    [state]
  )

  <MyContext.Provider value={value}>
    {children}
  </MyContext.Provider>
}
Enter fullscreen mode Exit fullscreen mode
// FunctionalComponent.jsx
// With `memo` we avoid the re-rendering if props didn't change
// Context value didn't change neither thanks to the previous 
// `useMemo`.
const Consumer = memo((props) => {
  const myContext = useContext(MyContext)
})
Enter fullscreen mode Exit fullscreen mode

But this is unlikely, you want always to wrap your entire application with your Providers as we saw previously.

ReactDOM.render(
  <MyProvider>
    <MyEntireApp/>
  </MyProvider>,
  document.getElementById('root'),
)
Enter fullscreen mode Exit fullscreen mode

Splitting Context in two: stateContext and setStateContext

For the same reasons we already talked about previously:

⚠️ A Consumer that just changes the state of a Context (by using setState or dispatch) will be re-rendered once the update is performed and the value changes.

That's why it is a good idea to split that context in two as follows:

const CountStateContext = createContext()
const CountUpdaterContext = createContext()
Enter fullscreen mode Exit fullscreen mode
const Provider = () => {
  const [count, setCount] = usetState(0)

  // We memoize the setCount in order to do not create a new
  // reference once `count` changes. An alternative would be
  // passing directly the setCount function (without 
  // implementation) via the provider's value or implementing its 
  // behaviour in our custom hook.
  const memoSetCount = useCallback(() => setCount((c) => c + 1), [
    setCount,
  ])

  return (
    <CountStateContext.Provider value={count}>
      <CountUpdaterContext.Provider value={memoSetCount}>
        {props.children}
      </CountUpdaterContext.Provider>
    </CountStateContext.Provider>
  )
}

const useCountState() {
  const countStateCtx = useContext(StateContext)
  if (typeof countStateCtx === 'undefined') {
    throw new Error('useCountState must be used within a Provider')
  }
  return countStateCtx 
}

function useCountUpdater() {
  const countUpdaterCtx = useContext(CountUpdaterContext)
  if (typeof countUpdaterCtx === 'undefined') {
    throw new Error('useCountUpdater must be used within a Provider')
  }
  // We could here implement setCount to avoid the previous useCallback
  // const setCount = () => countUpdaterCtx((c) => c + 1)
  // return setCount
  return countUpdaterCtx
}
Enter fullscreen mode Exit fullscreen mode
// CountConsumer.jsx
// This component will re-render if count changes.
const CountDisplay = () => {
  const count = useCountState()

  return (
    <>
      {`The current count is ${count}. `}
    </>
  )
})
Enter fullscreen mode Exit fullscreen mode
// CountDispatcher.jsx
// This component will not re-render if count changes.
const CounterDispatcher = () => {
  const countUpdater = useCountUpdater()

  return (
    <button onClick={countUpdater}>Increment count</button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Components that use both the state and the updater will have to import them like this:

const state = useCountState()
const dispatch = useCountDispatch()
Enter fullscreen mode Exit fullscreen mode

You can export both of them in a single function useCount doing this:

const useCount = () => {
  return [useCountState(), useCountDispatch()]
}
Enter fullscreen mode Exit fullscreen mode

What about using useReducer? Do I need to take in count everything we talked about?

Yes, of course. The unique difference about using the useReducer hook is that now you are not using setState in order to handle the state.

⚠️ Remember, React Context does not manage the state, you do it via useState or useReducer.

The possible optimization leaks remains the same we talked about this article.

React Context vs Redux

Let me link you an awesome article for this, authored by Mark "acemarke" Erikson, Redux mantainer:

Are Context and Redux the same thing?
No. They are different tools that do different things, and you use them for different purposes.

Is Context a "state management" tool?
No. Context is a form of Dependency Injection. It is a transport mechanism - it doesn't "manage" anything. Any "state management" is done by you and your own code, typically via useState/useReducer.

Are Context and useReducer a replacement for Redux?
No. They have some similarities and overlap, but there are major differences in their capabilities.

When should I use Context?
Any time you have some value that you want to make accessible to a portion of your React component tree, without passing that value down as props through each level of components.

When should I use Context and useReducer?
When you have moderately complex React component state management needs within a specific section of your application.

When should I use Redux instead?
Redux is most useful in cases when:

  • You have larger amounts of application state that are needed in many places in the app.
  • The app state is updated frequently over time.
  • The logic to update that state may be complex.
  • The app has a medium or large-sized codebase, and might be worked on by many people.
  • You want to be able to understand when, why, and how the state in your application has updated, and visualize the changes to your state over time.
  • You need more powerful capabilities for managing side effects, persistence, and data serialization.

https://blog.isquaredsoftware.com/2021/01/context-redux-differences/#context-and-usereducer

Testing

Let's test the following case: we have a Provider which fetchs asynchronously some Articles in order to make them available to our fellow Consumers.

We will work with the following mock:

[
  {
    "id": 1,
    "title": "Article1",
    "description": "Description1"
  },
  {
    "id": 2,
    "title": "Article2",
    "description": "Description2"
  }
]
Enter fullscreen mode Exit fullscreen mode
// ArticlesProvider.jsx
const ArticlesProvider = ({ children }) => {
  const [articles, setArticles] = useState([])

  const fetchArticles = async () => {
    const articles = await ArticlesService.get('/api/articles')

    setArticles(articles)
  }

  useEffect(() => {
    fetchArticles()
  }, [])

  return (
    <ArticlesContext.Provider value={{ articles, setArticles }}>
      {children}
    </ArticlesContext.Provider>
  )
}

const useArticles = () => {
  const articlesCtx = useContext(ArticlesContext)
  if (typeof articlesCtx === "undefined") {
    throw new Error("articlesCtx must be used within a Provider")
  }
  return articlesCtx
}

export { ArticlesProvider, useArticles }
Enter fullscreen mode Exit fullscreen mode
// ArticlesProvider.spec.jsx
describe("ArticlesProvider", () => {
  const noContextAvailable = "No context available."
  const contextAvailable = "Articles context available."

  const articlesPromise = new Promise((resolve) => resolve(articlesMock))
  ArticlesService.get = jest.fn(() => articlesPromise)

  // ❌ This code fragment is extracted directly from Testing Library
  // documentation but I don't really like it, since here we are
  // testing the `<ArticlesContext.Provider>` functionality, not
  // our `ArticlesProvider`.
  const renderWithProvider = (ui, { providerProps, ...renderOptions }) => {
    return render(
      <ArticlesContext.Provider {...providerProps}>
        {ui}
      </ArticlesContext.Provider>,
      renderOptions
    )
  }

  // ✔️ Now we are good to go, we test what our Consumers will actually use.
  const renderWithProvider = (ui, { ...renderOptions }) => {
    return render(<ArticlesProvider>{ui}</ArticlesProvider>, renderOptions)
  }

  // ⚠️ We mock a Consumer in order to test our Provider.
  const ArticlesComsumerMock = (
    <ArticlesContext.Consumer>
      {(articlesCtx) => articlesCtx ? (
          articlesCtx.articles.length > 0 &&
            articlesCtx.setArticles instanceof Function && (
              <span>{contextAvailable}</span>
            )
        ) : (
          <span>{noContextAvailable}</span>
        )
      }
    </ArticlesContext.Consumer>
  )

  it("should no render any articles if no provider is found", () => {
    render(ArticlesComsumerMock)

    expect(screen.getByText(noContextAvailable)).toBeInTheDocument()
  })

  it("should render the articles are available", async () => {
    renderWithProvider(ArticlesComsumerMock)

    await waitFor(() => {
      expect(screen.getByText(contextAvailable)).toBeInTheDocument()
    })
  })
})

Enter fullscreen mode Exit fullscreen mode

Time to test our Consumer:

// Articles.jsx
const Articles = () => {
  const { articles } = useArticles()

  return (
    <>
      <h2>List of Articles</h2>
      {articles.map((article) => (
        <p>{article.title}</p>
      ))}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode
// Articles.spec.jsx
describe("Articles", () => {
  const articlesPromise = new Promise((resolve) => resolve(articlesMock))
  ArticlesService.get = jest.fn(() => articlesPromise)

  const renderWithProvider = (ui, { ...renderOptions }) => {
    return render(<ArticlesProvider>{ui}</ArticlesProvider>, renderOptions)
  }

  it("should render the articles list", async () => {
    renderWithProvider(<Articles />)

    await waitFor(() => {
      expect(screen.getByText("List of Articles")).toBeInTheDocument()
    })

    articlesMock.forEach((article) => {
      expect(screen.getByText(article.title)).toBeInTheDocument()
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

Unstable feature: observed bits

// react/index.d.ts
function useContext<T>(context: Context<T>/*, (not public API) observedBits?: number|boolean */): T;
Enter fullscreen mode Exit fullscreen mode

observedBits is a hidden experimental feature that represent what context values did change.

We can prevent unnecessary re-renders in a Global state by calculating what bits changed and telling our components to observe the bits we are using.

// globalContext.js
import { createContext } from 'react';

const store = {
  // The bit we want to observe
  observedBits: {
    theme: 0b001,
    authentified: 0b010,
    translations: 0b100
  },
  initialState: {
    theme: 'dark',
    authentified: false,
    translations: {}
  }
};

const getChangedBits = (prev, next) => {
  let result = 0;

  // ⚠️ With `result OR bits[key]` we calculate the total bits
  // that changed, if only `theme` changed we will get 0b001,
  // if the three values changed we will get: 0b111.
  Object.entries(prev.state).forEach(([key, value]) => {
    if (value !== next.state[key]) {
      result = result | store.observedBits[key];
    }
  });
  return result;
};

const GlobalContext = createContext(undefined, getChangedBits);

export { GlobalContext, store };
Enter fullscreen mode Exit fullscreen mode
// Theme.jsx
const Theme = () => {
  console.log('Re-render <Theme />');
  // ⚠️ No matter if the state changes, this component will only
  // re-render if the theme is updated
  const { state } = useContext(GlobalContext, store.observedBits.theme);

  return <p>Current theme: {state.theme}</p>;
};
Enter fullscreen mode Exit fullscreen mode

Keep in mind this is an unstable feature, you are limited to observe 30 values (MaxInt.js) and you will be warned in console :P. I would prefer splitting contexts to pass the necessary props to your application tree, following the initial nature of React Context, while waiting for updates.

A complete demo with a functional playground of this can be found here: https://stackblitz.com/edit/react-jtb3lv

The Future

There are already some proposals to implement the selector concept, in order to let React manage these optimizations if we are just observing one value in a global state:

const context = useContextSelector(Context, c => c.selectedField)
Enter fullscreen mode Exit fullscreen mode

https://github.com/facebook/react/pull/20646

Bibliography

Interesting articles/comments I have been reading so far that helped me to put all the pieces together, including some stackblitz to play with the re-renders:

Key Points

  • When the nearest Provider above the component is updated, this component will trigger a re-render even if an ancestor uses React.memo or shouldComponentUpdate.
  • The value that React.createContext(...) is receiving as default will be the one a Consumer will receive if it does not have any Provider above itself in the component tree.
  • In order to avoid the re-rendering of the entire app (or the use of memo), the Provider must receive children as a prop to keep the references equal.
  • If you implement a Global Provider, no matter what property will be update a Consumer will always trigger a re-render.
  • If Parent can be updated (via setState() or even via a grandparent) we must be careful since everything will be re-rendered. We will have to memo both the Provider and the Consumers.
  • A Consumer that just changes the state of a Context (by using setState or dispatch) will be re-rendered once the update is performed and the value changes, so it is recommend to split that Context in two: StateContext and DispatchContext.
  • Remember, React Context does not manage the state, you do it via useState or useReducer.
  • Implement a custom mock in order to properly test your Provider, <Context.Provider {...props} /> is not what your components will directly consume.
  • observedBits is an hidden experimental feature which can helps us to implement a global state avoiding unnecessary re-renders.

That was it, hope you like it!

Top comments (0)