React Context
React Context can help us share "global" data without having to manually pass it down between descendant components at different nesting levels of the tree.
Context is designed to share "global" state for a tree of React components.
Let's explore React Context by managing state of a todo list app.
How React Context works
To use React context, we first need to create a context object using React.createContext()
. This object will expose a Provider
and a Consumer
.
const TodoContext = React.createContext();
- Provider: Allows consuming components to subscribe to context changes.
- Consumer: Components that subscribe to context changes.
Provider
With the TodoContext
in place, we can use the Provider
property to create a wrapper component to allow descendent components of this provider to subscribe to context changes. The provider accepts a value
prop to setup the context.
Context.Provider
<TodoContext.Provider value={/* some value */}> <YourComponent /> </TodoContext.Provider>
React.useReducer
To handle a complex state object, we can use the useReducer
hook. This hook accepts a reducer function and the application's initial state as the second argument.
// todoReducer.js function todoReducer(state, action) { const { payload, type } = action; switch (type) { case 'ADD_TODO': return [ ...state, { id: Math.floor(Math.random() * 1000), title: payload.title, }, ]; case 'REMOVE_TODO': return state.filter(todo => todo.id !== payload.id); default: { throw new Error(`Unhandled action type: ${type}`); } } } const [state, dispatch] = useReducer(todoReducer, []);
Now, we can pass an object with state
and dispatch
to the provider prop. But this is not the best approach for our use case because it will make all components in the tree to re-render on state changes, regardless if they are subscribed or not to context changes.
Wrong approach for this use case
function Todo() { const [todos, dispatch] = React.useReducer(todoReducer, []); return ( <TodoContext.Provider value={{ todos, dispatch }}> <YourComponent /> </TodoContext.Provider> ); }
To avoid unnecessary, re-renders we can create two separate contexts. One for the state and one for the dispatch.
import { todoReducer } from './reducer/todoReducer'; const TodoStateContext = React.createContext(); const TodoDispatchContext = React.createContext(); function TodoContext({ children }) { const [state, dispatch] = React.useReducer(todoReducer, []); return ( <TodoStateContext.Provider value={state}> <TodoDispatchContext.Provider value={dispatch}> {children} </TodoDispatchContext.Provider> </TodoStateContext.Provider> ); }
The TodoContext
functional component is encapsulating the state and dispatch function to allow children components to subscribe and emit context changes.
The Consumer Hook
The useContext
hook gives us a access the provider context. The useContext
hook only takes the context object itself as a parameter and returns the current context of the nearest Provider
prop value. We avoided unnecessary re-renders by splitting contexts that don't change together. Last, we can improve the developer experience by throwing a helpful error message if the hook is outside the context provider.
function useTodoState() { const context = React.useContext(TodoStateContext); if (context === undefined) { throw new Error('useTodoState must be used within TodoStateContext'); } return context; } function useTodoDispatch() { const context = React.useContext(TodoDispatchContext); if (context === undefined) { throw new Error('useTodoDispatch must be used within TodoStateContext'); } return context; }
import React from 'react'; import AddTodo from './AddTodo'; import TodoList from './TodoList'; import TodoCounter from './TodoCounter'; import { TodoContext } from './TodoContext'; function Todo() { return ( <TodoContext> <TodoCounter /> <AddTodo /> <TodoList /> </TodoContext> ); }
The TodoContext
provides access to the value and dispatch function of our useReducer hook to any child component.
Any child component can subscribe to the state via
useTodoState
.
import React from 'react'; import TodoItem from './TodoItem'; import { useTodoState } from './TodoContext'; const TodoList = () => { const todos = useTodoState(); return todos.map(todo => <TodoItem key={todo.id} todo={todo} />); };
We can dispatch changes to our state using our
useTodoDispatch
.
import React from 'react'; import { useTodoDispatch } from './TodoContext'; const TodoItem = ({ todo }) => { const dispatch = useTodoDispatch(); const onDelete = () => { dispatch({ type: 'REMOVE_TODO', payload: todo }); }; return ( <div> <hr /> <span>{todo.title}</span> <button onClick={onDelete}>Delete</button> </div> ); };
import React, { useState } from 'react'; import { useTodoDispatch } from './TodoContext'; const AddTodo = () => { const [todo, setTodo] = useState(''); const dispatch = useTodoDispatch(); const handleSubmit = e => { e.preventDefault(); if (!todo) return; dispatch({ type: 'ADD_TODO', payload: { title: todo } }); setTodo(''); }; return ( <> <input type="text" value={todo} onChange={e => setTodo(e.target.value)} /> <button onClick={handleSubmit}>Add Todo</button> </> ); };
import { useTodoState } from './TodoContext'; const TodoCounter = () => { const todos = useTodoState(); return <h2>Total of todos: {todos.length}</h2>; };
Hey, I'm Ignacio Villamar
Senior Frontend Engineer, living in the NYC metro area.
Follow @ivstudio