Photo by Pathum Danthanarayana
How to create and expose React Context providers and consumers
In Application State Management with React, I talk about how using a mix of local state and React Context can help you manage state well in any React application. I showed some examples and I want to call out a few things about those examples and how you can create React context consumers effectively so you avoid some problems and improve the developer experience and maintainability of the context objects you create for your application and/or libraries.
Note, please do read Application State Management with React and follow the advice that you shouldn't be reaching for context to solve every state sharing problem that crosses your desk. But when you do need to reach for context, hopefully this blog post will help you know how to do so effectively. Also, remember that context does NOT have to be global to the whole app, but can be applied to one part of your tree and you can (and probably should) have multiple logically separated contexts in your app.
First, let's create a file at src/count-context.js
and we'll create our context there:
1// src/count-context.js2import React from 'react'34const CountStateContext = React.createContext()5const CountDispatchContext = React.createContext()
First off, I don't have an initial value for the CountStateContext
. If I wanted an initial value, I would call React.createContext({count: 0})
. But I don't include a default value and that's intentional. The defaultValue
is only useful in a situation like this:
1function CountDisplay() {2 const {count} = React.useContext(CountStateContext)3 return <div>{count}div>4}56ReactDOM.render(<CountDisplay />, document.getElementById('⚛️'))
Because we don't have a default value for our CountStateContext
, we'll get an error on the highlighted line where we're destructing the return value of useContext
. This is because our default value is undefined
and you cannot destructure undefined
.
None of us likes runtime errors, so your knee-jerk reaction may be to add a default value to avoid the runtime error. However, what use would the context be if it didn't have an actual value? If it's just using the default value that's been provided, then it can't really do much good. 99% of the time that you're going to be creating and using context in your application, you want your context consumers (those using useContext
) to be rendered within a provider which can provide a useful value.
Note, there are situations where default values are useful, but most of the time they're not necessary or useful.
The React docs suggest that providing a default value "can be helpful in testing components in isolation without wrapping them." While it's true that it allows you to do this, I disagree that it's better than wrapping your components with the necessary context. Remember that every time you do something in your test that you don't do in your application, you reduce the amount of confidence that test can give you. There are reasons to do this, but that's not one of them.
Note: If you're using Flow or TypeScript, not providing a default value can be really annoying for people who are using
React.useContext
, but I'll show you how to avoid that problem altogether below. Keep reading!
What's this CountDispatchContext
thing all about? I've been playing around with context for a while, and talking with friends at Facebook who have been playing around with it for longer and I can tell you that the simplest way to avoid problems with context (especially when you start calling dispatch
in effects) is to split up the state and dispatch in context. Stay with me here!
If you want to dive into this a bit more, then read How to optimize your context value
Ok, let's continue. For this context module to be useful at all we need to use the Provider and expose a component that provides a value. Our component will be used like this:
1function App() {2 return (3 <CountProvider>4 <CountDisplay />5 <Counter />6 CountProvider>7 )8}910ReactDOM.render(<App />, document.getElementById('⚛️'))
So let's make a component that can be used like that:
1// src/count-context.js2import React from 'react'34const CountStateContext = React.createContext()5const CountDispatchContext = React.createContext()67function countReducer(state, action) {8 switch (action.type) {9 case 'increment': {10 return {count: state.count + 1}11 }12 case 'decrement': {13 return {count: state.count - 1}14 }15 default: {16 throw new Error(`Unhandled action type: ${action.type}`)17 }18 }19}2021function CountProvider({children}) {22 const [state, dispatch] = React.useReducer(countReducer, {count: 0})23 return (24 <CountStateContext.Provider value={state}>25 <CountDispatchContext.Provider value={dispatch}>26 {children}27 CountDispatchContext.Provider>28 CountStateContext.Provider>29 )30}3132export {CountProvider}
NOTE: this is a contrived example that I'm intentionally over-engineering to show you what a more real-world scenario would be like. This does not mean it has to be this complicated every time! Feel free to use
useState
if that suites your scenario. In addition, some providers are going to be short and simple like this, and others are going to be MUCH more involved with many hooks.
Most of the APIs for context usages I've seen in the wild look something like this:
1import React from 'react'2import {SomethingContext} from 'some-context-package'34function YourComponent() {5 const something = React.useContext(SomethingContext)6}
But I think that's a missed opportunity at providing a better user experience. Instead, I think it should be like this:
1import React from 'react'2import {useSomething} from 'some-context-package'34function YourComponent() {5 const something = useSomething()6}
This has the benefit of you being able to do a few things which I'll show you in the implementation now:
1// src/count-context.js2import React from 'react'34const CountStateContext = React.createContext()5const CountDispatchContext = React.createContext()67function countReducer(state, action) {8 switch (action.type) {9 case 'increment': {10 return {count: state.count + 1}11 }12 case 'decrement': {13 return {count: state.count - 1}14 }15 default: {16 throw new Error(`Unhandled action type: ${action.type}`)17 }18 }19}2021function CountProvider({children}) {22 const [state, dispatch] = React.useReducer(countReducer, {count: 0})23 return (24 <CountStateContext.Provider value={state}>25 <CountDispatchContext.Provider value={dispatch}>26 {children}27 CountDispatchContext.Provider>28 CountStateContext.Provider>29 )30}3132function useCountState() {33 const context = React.useContext(CountStateContext)34 if (context === undefined) {35 throw new Error('useCountState must be used within a CountProvider')36 }37 return context38}3940function useCountDispatch() {41 const context = React.useContext(CountDispatchContext)42 if (context === undefined) {43 throw new Error('useCountDispatch must be used within a CountProvider')44 }45 return context46}4748export {CountProvider, useCountState, useCountDispatch}
First, the useCountState
and useCountDispatch
custom hooks use React.useContext
to get the provided context value from the nearest CountProvider
. However, if there is no value, then we throw a helpful error message indicating that the hook is not being called within a function component that is rendered within a CountProvider
. This is most certainly a mistake, so providing the error message is valuable. #FailFast
If you're able to use hooks at all, then skip this section. However if you need to support React <
16.8.0, or you think the Context needs to be consumed by class components, then here's how you could do something similar with the render-prop based API for context consumers:
1function CountConsumer({children}) {2 return (3 <CountContext.Consumer>4 {context => {5 if (context === undefined) {6 throw new Error('CountConsumer must be used within a CountProvider')7 }8 return children(context)9 }}10 CountContext.Consumer>11 )12}
This is what I used to do before we had hooks and it worked well. I would not recommend bothering with this if you can use hooks though. Hooks are much better.
I promised I'd show you how to avoid issues with skipping the defaultValue
when using TypeScript or Flow. Guess what! By doing what I'm suggesting, you avoid the problem by default! It's actually not a problem at all. Check it out:
1// src/count-context.tsx2import * as React from 'react'34type Action = {type: 'increment'} | {type: 'decrement'}5type Dispatch = (action: Action) => void6type State = {count: number}7type CountProviderProps = {children: React.ReactNode}89const CountStateContext = React.createContext<State | undefined>(undefined)10const CountDispatchContext = React.createContext<Dispatch | undefined>(11 undefined,12)1314function countReducer(state: State, action: Action) {15 switch (action.type) {16 case 'increment': {17 return {count: state.count + 1}18 }19 case 'decrement': {20 return {count: state.count - 1}21 }22 default: {23 throw new Error(`Unhandled action type: ${action.type}`)24 }25 }26}2728function CountProvider({children}: CountProviderProps) {29 const [state, dispatch] = React.useReducer(countReducer, {count: 0})3031 return (32 <CountStateContext.Provider value={state}>33 <CountDispatchContext.Provider value={dispatch}>34 {children}35 </CountDispatchContext.Provider>36 </CountStateContext.Provider>37 )38}3940function useCountState() {41 const context = React.useContext(CountStateContext)42 if (context === undefined) {43 throw new Error('useCountState must be used within a CountProvider')44 }45 return context46}4748function useCountDispatch() {49 const context = React.useContext(CountDispatchContext)50 if (context === undefined) {51 throw new Error('useCountDispatch must be used within a CountProvider')52 }53 return context54}5556export {CountProvider, useCountState, useCountDispatch}
With that, anyone can use useCountState
or useCountDispatch
without having to do any undefined-checks, because we're doing it for them!
At this point, you reduxers are yelling: "Hey, where are the action creators?!" If you want to implement action creators that is fine by me, but I never liked action creators. I have always felt like they were an unnecessary abstraction. Also, if you are using TypeScript or Flow and have your actions well typed, then you should not need them. You can get autocomplete and inline type errors!
I really like passing dispatch
this way and as a side benefit, dispatch
is stable for the lifetime of the component that created it, so you don't need to worry about passing it to useEffect
dependencies lists (it makes no difference whether it is included or not).
If you are not typing your JavaScript (you probably should consider it if you have not), then the error we throw for missed action types is a failsafe. Also, read on to the next section because this can help you too.
This is a great question. What happens if you have a situation where you need to make some asynchronous request and you need to dispatch multiple things over the course of that request? Sure you could do it at the calling component, but manually wiring all of that together for every component that needs to do something like that would be pretty annoying.
What I suggest is you make a helper function within your context module which accepts dispatch
along with any other data you need, and make that helper be responsible for dealing with all of that. Here's an example from my Advanced React Patterns workshop:
1// user-context.js2async function updateUser(dispatch, user, updates) {3 dispatch({type: 'start update', updates})4 try {5 const updatedUser = await userClient.updateUser(user, updates)6 dispatch({type: 'finish update', updatedUser})7 } catch (error) {8 dispatch({type: 'fail update', error})9 }10}1112export {UserProvider, useUserDispatch, useUserState, updateUser}
Then you can use that like this:
1// user-profile.js23import {useUserState, useUserDispatch, updateUser} from './user-context'45function UserSettings() {6 const {user, status, error} = useUserState()7 const userDispatch = useUserDispatch()89 function handleSubmit(event) {10 event.preventDefault()11 updateUser(userDispatch, user, formState)12 }1314 // more code...15}
I'm really happy with this pattern and if you'd like me to teach this at your company let me know (or add yourself to the waitlist for the next time I host the workshop)!
Some people find this annoying/overly verbose:
1const state = useCountState()2const dispatch = useCountDispatch()
They say "can't we just do this?":
1const [state, dispatch] = useCount()
Sure you can:
1function useCount() {2 return [useCountState(), useCountDispatch()]3}
So here's the final version of the code:
1// src/count-context.js2import React from 'react'34const CountStateContext = React.createContext()5const CountDispatchContext = React.createContext()67function countReducer(state, action) {8 switch (action.type) {9 case 'increment': {10 return {count: state.count + 1}11 }12 case 'decrement': {13 return {count: state.count - 1}14 }15 default: {16 throw new Error(`Unhandled action type: ${action.type}`)17 }18 }19}2021function CountProvider({children}) {22 const [state, dispatch] = React.useReducer(countReducer, {count: 0})23 return (24 <CountStateContext.Provider value={state}>25 <CountDispatchContext.Provider value={dispatch}>26 {children}27 CountDispatchContext.Provider>28 CountStateContext.Provider>29 )30}3132function useCountState() {33 const context = React.useContext(CountStateContext)34 if (context === undefined) {35 throw new Error('useCountState must be used within a CountProvider')36 }37 return context38}3940function useCountDispatch() {41 const context = React.useContext(CountDispatchContext)42 if (context === undefined) {43 throw new Error('useCountDispatch must be used within a CountProvider')44 }45 return context46}4748export {CountProvider, useCountState, useCountDispatch}
Note that I'm NOT exporting CountContext
. This is intentional. I expose only one way to provide the context value and only one way to consume it. This allows me to ensure that people are using the context value the way it should be and it allows me to provide useful utilities for my consumers.
I hope this is useful to you! Remember:
- You shouldn't be reaching for context to solve every state sharing problem that crosses your desk.
- Context does NOT have to be global to the whole app, but can be applied to one part of your tree
- You can (and probably should) have multiple logically separated contexts in your app.
Good luck!