Effective Context: Moving Beyond Default-Value Pitfalls
React Context provides a clean way to share state across a component tree without prop drilling. However, its implementation details can lead to subtle bugs and poor developer experiences if you simply slap a default value on React.createContext and expose the raw object to your consumers.
To start, create a dedicated module, src/count-context.js, where the context is defined:
import * as React from 'react'
const CountContext = React.createContext()
Deliberately omitting a default value is the right first decision. While createContext({count: 0}) is valid, a default is only truly useful in rare and isolated scenarios:
function CountDisplay() {
const { count } = React.useContext(CountContext)
return <div>{count}</div>
}
ReactDOM.render(<CountDisplay />, document.getElementById('⚛️'))
Without a default, destructuring the result of useContext will throw an error because the context value is undefined. The natural reaction is to add a default to silence the runtime error—but resist that urge. A context defaulted to a static value is nearly useless; meaningful data only exists within a provider. Removing the default forces errors to surface so they are caught during development rather than creating confusing downstream behavior.
The tradeoff for stricter runtime correctness is that TypeScript users will have to handle a potentially undefined type from useContext. The solution below eliminates that friction entirely.
One Provider, A Custom Hook
Keeping the context object private is key. Instead of exporting CountContext wholesale, the module exposes two targeted APIs: a custom Provider component and a custom consumer hook.
The intended usage of the Provider looks like this:
function App() {
return (
<CountProvider>
<CountDisplay />
<Counter />
</CountProvider>
)
}
ReactDOM.render(<App />, document.getElementById('⚛️'))
The corresponding implementation:
import * as React from 'react'
const CountContext = React.createContext()
function countReducer(state, action) {
switch (action.type) {
case 'increment': {
return { count: state.count + 1 }
}
case 'decrement': {
return { count: state.count - 1 }
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function CountProvider({ children }) {
const [state, dispatch] = React.useReducer(countReducer, { count: 0 })
// NOTE: you *might* need to memoize this value
// Learn more in http://kcd.im/optimize-context
const value = { state, dispatch }
return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}
export { CountProvider }
Next, instead of expecting consumers to run useContext(CountContext) themselves, the module offers a useCount hook:
import * as React from 'react'
import { useSomething } from 'some-context-package'
function YourComponent() {
const something = useSomething()
}
This pattern gives you a single point of control. The custom hook internally calls React.useContext and adds an essential guard:
import * as React from 'react'
const CountContext = React.createContext()
function countReducer(state, action) {
switch (action.type) {
case 'increment': {
return { count: state.count + 1 }
}
case 'decrement': {
return { count: state.count - 1 }
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function CountProvider({ children }) {
const [state, dispatch] = React.useReducer(countReducer, { count: 0 })
// NOTE: you *might* need to memoize this value
// Learn more in http://kcd.im/optimize-context
const value = { state, dispatch }
return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}
function useCount() {
const context = React.useContext(CountContext)
if (context === undefined) {
throw new Error('useCount must be used within a CountProvider')
}
return context
}
export { CountProvider, useCount }
If useCount is invoked anywhere that is not within a matching Provider, a clear, explicit error is thrown immediately. This #FailFast approach is far superior to allowing components to silently fail or crash later. Best of all, TypeScript users completely sidestep the missing-default-value issue—since the custom hook checks for the value internally:
import * as React from 'react'
type Action = { type: 'increment' } | { type: 'decrement' }
type Dispatch = (action: Action) => void
type State = { count: number }
type CountProviderProps = { children: React.ReactNode }
const CountStateContext = React.createContext<
{ state: State; dispatch: Dispatch } | undefined
>(undefined)
function countReducer(state: State, action: Action) {
switch (action.type) {
case 'increment': {
return { count: state.count + 1 }
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function CountProvider({ children }: CountProviderProps) {
const [state, dispatch] = React.useReducer(countReducer, { count: 0 })
// NOTE: you *might* need to memoize this value
// Learn more in http://kcd.im/optimize-context
const value = { state, dispatch }
return (
<CountStateContext.Provider value={value}>
{children}
</CountStateContext.Provider>
)
}
function useCount() {
const context = React.useContext(CountStateContext)
if (context === undefined) {
throw new Error('useCount must be used within a CountProvider')
}
return context
}
export { CountProvider, useCount }
The hook offers a seamless and typesafe contract for all consumers.
Supporting Class Components
Though hooks return APIs are the modern choice, you may need to accommodate class components or React versions older than 16.8.0. A render-prop based consumer component provides the same functionality that hooks would for functional components:
function CountConsumer({ children }) {
return (
<CountContext.Consumer>
{(context) => {
if (context === undefined) {
throw new Error('CountConsumer must be used within a CountProvider')
}
return children(context)
}}
</CountContext.Consumer>
)
}
Class components would consume this component in the following manner:
class CounterThing extends React.Component {
render() {
return (
<CountConsumer>
{({ state, dispatch }) => (
<div>
<div>{state.count}</div>
<button onClick={() => dispatch({ type: 'decrement' })}>
Decrement
</button>
<button onClick={() => dispatch({ type: 'increment' })}>
Increment
</button>
</div>
)}
</CountConsumer>
)
}
}
This render-prop structure is a viable fallback, though hooks remain preferred in new code where supported.
The Case for Dispatch Over An Action Layer
The examples above use a simple state counter for clarity, but dispatch functions can just as easily be passed down:
This mitigates the need for action creators or abstraction layers.
Your dispatch function is also inherently stable; it will not change during the component's lifetime. This means it does not need to be re-created or re-imported for useEffect dependency arrays—simply pass it along without concern:
Managing Async Actions
For more complex operations like an asynchronous request, avoid wiring dispatch logic into each component. Instead, centralize it by adding a helper function directly within the context module:
async function updateUser(dispatch, user, updates) {
dispatch({ type: 'start update', updates })
try {
const updatedUser = await userClient.updateUser(user, updates)
dispatch({ type: 'finish update', updatedUser })
} catch (error) {
dispatch({ type: 'fail update', error })
}
}
export { UserProvider, useUser, updateUser }
The helper handles multi-step dispatch sequences internally, so the calling component only needs to invoke a simple function:
import { useUser, updateUser } from './user-context'
function UserSettings() {
const [{ user, status, error }, userDispatch] = useUser()
function handleSubmit(event) {
event.preventDefault()
updateUser(userDispatch, user, formState)
}
// more code...
}
This promotes a single source of truth and keeps calling code concise and readable.
The Complete Pattern
The finished module contains a robust, full-featured Context implementation:
- A
import * as React from 'react'
const CountContext = React.createContext()
function countReducer(state, action) {
switch (action.type) {
case 'increment': {
return { count: state.count + 1 }
}
case 'decrement': {
return { count: state.count - 1 }
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function CountProvider({ children }) {
const [state, dispatch] = React.useReducer(countReducer, { count: 0 })
// NOTE: you *might* need to memoize this value
// Learn more in http://kcd.im/optimize-context
const value = { state, dispatch }
return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}
function useCount() {
const context = React.useContext(CountContext)
if (context === undefined) {
throw new Error('useCount must be used within a CountProvider')
}
return context
}
export { CountProvider, useCount }
Whatever your specific implementation, always remember these fundamentals: reserve Context for genuine state managers rather than every prop-drilling concern, keep Context scoped to the necessary portion of the component tree, and feel free to use several independently scoped contexts.



