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.

Edit vigorous-leftpad-5inv8


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.

Better approach

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.

TodoContext.js

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; }

Todo.js

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.

TodoList.js

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.

TodoItem.js

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> ); };

AddTodo.js

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> </> ); };

TodoCounter.js

import { useTodoState } from './TodoContext'; const TodoCounter = () => { const todos = useTodoState(); return <h2>Total of todos: {todos.length}</h2>; };

Edit vigorous-leftpad-5inv8