One liner: The Context Module Functions Pattern allows you to encapsulate a complex set of state changes into a utility function which can be tree-shaken and lazily loaded.
Let's take a look at an example of a simple context and a reducer combo:
const CounterContext = React.createContext();
function CounterProvider({ step = 1, initialCount = 0, ...props }) {
const [state, dispatch] = React.useReducer(
(state, action) => {
const change = action.step ?? step;
switch (action.type) {
case 'increment': {
return { ...state, count: state.count + change };
}
case 'decrement': {
return { ...state, count: state.count - change };
}
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
}
},
{ count: initialCount }
);
const value = [state, dispatch];
return <CounterContext.Provider value={value} {...props} />;
}
function useCounter() {
const context = React.useContext(CounterContext);
if (context === undefined) {
throw new Error(`useCounter must be used within a CounterProvider`);
}
return context;
}
export { CounterProvider, useCounter };
// src/screens/counter.js
import { useCounter } from 'context/counter';
function Counter() {
const [state, dispatch] = useCounter();
const increment = () => dispatch({ type: 'increment' });
const decrement = () => dispatch({ type: 'decrement' });
return (
<div>
<div>Current Count: {state.count}</div>
<button onClick={decrement}>-</button>
<button onClick={increment}>+</button>
</div>
);
}
// src/index.js
import { CounterProvider } from 'context/counter';
function App() {
return (
<CounterProvider>
<Counter />
</CounterProvider>
);
}
I want to focus in on the user of our reducer (the Counter
component). Notice
that they have to create their own increment
and decrement
functions which
call dispatch
. I don't think that's a super great API. It becomes even more of
an annoyance when you have a sequence of dispatch
functions that need to be
called (like you'll see in our exercise).
The first inclination is to create "helper" functions and include them in the
context. Let's do that. You'll notice that we have to put it in
React.useCallback
so we can list our "helper" functions in dependency lists):
const increment = React.useCallback(
() => dispatch({ type: 'increment' }),
[dispatch]
);
const decrement = React.useCallback(
() => dispatch({ type: 'decrement' }),
[dispatch]
);
const value = { state, increment, decrement };
return <CounterContext.Provider value={value} {...props} />;
// now users can consume it like this:
const { state, increment, decrement } = useCounter();
This isn't a bad solution necessarily. But as my friend Dan says:
Helper methods are object junk that we need to recreate and compare for no purpose other than superficially nicer looking syntax.
What Dan recommends (and what Facebook does) is pass dispatch as we had
originally. And to solve the annoyance we were trying to solve in the first
place, they use importable "helpers" that accept dispatch
. Let's take a look
at how that would look:
// src/context/counter.js
const CounterContext = React.createContext();
function CounterProvider({ step = 1, initialCount = 0, ...props }) {
const [state, dispatch] = React.useReducer(
(state, action) => {
const change = action.step ?? step;
switch (action.type) {
case 'increment': {
return { ...state, count: state.count + change };
}
case 'decrement': {
return { ...state, count: state.count - change };
}
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
}
},
{ count: initialCount }
);
const value = [state, dispatch];
return <CounterContext.Provider value={value} {...props} />;
}
function useCounter() {
const context = React.useContext(CounterContext);
if (context === undefined) {
throw new Error(`useCounter must be used within a CounterProvider`);
}
return context;
}
const increment = (dispatch) => dispatch({ type: 'increment' });
const decrement = (dispatch) => dispatch({ type: 'decrement' });
export { CounterProvider, useCounter, increment, decrement };
// src/screens/counter.js
import { useCounter, increment, decrement } from 'context/counter';
function Counter() {
const [state, dispatch] = useCounter();
return (
<div>
<div>Current Count: {state.count}</div>
<button onClick={() => decrement(dispatch)}>-</button>
<button onClick={() => increment(dispatch)}>+</button>
</div>
);
}
This may look like overkill, and it is. However, in some situations this pattern can not only help you reduce duplication, but it also helps improve performance and helps you avoid mistakes in dependency lists.
I wouldn't recommend this all the time, but sometimes it can be a help!
📜 If you need to review the context API, here are the docs:
🦉 Tip: You may notice that the context provider/consumers in React DevTools
just display as Context.Provider
and Context.Consumer
. That doesn't do a
good job differentiating itself from other contexts that may be in your app.
Luckily, you can set the context displayName
and it'll display that name for
the Provider
and Consumer
. Hopefully in the future this will happen
automatically (learn more).
const MyContext = React.createContext();
MyContext.displayName = 'MyContext';
👨💼 We have a user settings page where we render a form for the user's information. We'll be storing the user's information in context and we'll follow some patterns for exposing ways to keep that context updated as well as interacting with the backend.
💰 In this exercise, if you enter the text "fail" in the tagline or biography input, then the "backend" will reject the promise so you can test the error case.
Right now the UserSettings
form is calling userDispatch
directly. Your job
is to move that to a module-level "helper" function that accepts dispatch as
well as the rest of the information that's needed to execute the sequence of
dispatches.
🦉 To keep things simple we're leaving everything in one file, but normally you'll put the context in a separate module.