Javier Casas

A random walk through computer science

React Hooks Context Patterns

React Hooks is a polemic (but effective) change to how you can construct React components and applications. From it, some interesting patterns arise. In this case, we will explore a pair of patterns that derive from using the useContext hook, and React contexts in general.

Context as a global state piece

React didn't have a good story for global state. Either you went with Redux (and now you have to deal with Redux) or you pass the state around all over the place, and then your components are full of props that only exist because a sub-component needs some value. Either case is potentially annoying to use.

But Hooks introduces a new approach. The useContext hook allows you to define a context object to affect a subtree of your React application. Essentially, this allows you to have a pseudo-global scoped entity in a subtree of your React application. This pseudo-global entity can be a state to be shared, as well as an API to use it.

Let's see an example. For this, we will use a ToDo list, but we will implement it as a useContext state provider.

First, the context/state component

const ToDoListContext = React.createContext(null);

export function ToDoListGlobal = ({ children }) => {
  // The state
  const [todos, setTodos] = useState([]);  // Initially, the list of todos is empty

  // The API to use the state
  function addTodo(newTodoName) {
    setTodos([...todos, {name: newTodoName, completed: false}]);
  }

  function completeTodo(todoName) {
    setTodos(todos.map(
      todo => ({
        name: todo.name,
        completed: todoName === todo.name ? true : todo.completed })
    ));
  }

  const api = {
    todos,
    completeTodo,
    addTodo,
  }

  return <ToDoListContext.Provider value={api}>{children}</ToDoListContext.Provider>;
}

export const useToDoGlobal = () => useContext(ToDoListContext);

Then we use it in the App:

const App = () => (
  <ToDoListGlobal>
    <Switch>
      <Route exact path='/' component={Main} />
      <Route exact path='/completeTodo/:todoName' component={CompleteTodo} />
    </Switch>
  </ToDoListGlobal>
);

Now, within the Switch, and the Routes, and the components at the routes (Main and EditTodo), we can use the context, like this:

export const CompleteTodo = () => {
  const { todoName } = useParams();
  const { todos, completeTodo } = useToDoGlobal();

  const todo = todos.find(todo => todo.name = todoName);

  return <>
    <h1>Complete TODO</h1>
    Name: {todo.name}
    <button onClick={() => completeTodo(todoName)}>Complete TODO</button>
  </>;
}

With this, we can construct something similar to Redux, but without using Redux. We have an API with the shape that best suits us, provided by useToDoGlobal. It's kind of a global, but it's not a real global. It's only available in the context, and we can have multiple of them by having different context providers.

Context as items related to current entity

A variant of the previous idea is having a context that implements an API for viewing and modifying entities related to the one in the context. This is especially useful for creating simple components that don't have to deal with the details of the entities. For example, let's consider that our ToDo app now uses a backend to fetch the list of ToDos, and also you can add comments to each ToDo.

First, a context to view ToDos from the backend, including API:

const ToDoContext = React.createContext(null);

export function ToDoContext = ({ todoId, children }) => {

  const [todo, setTodo] = useState(null);

  useEffect(() => {
    setTodo(null);
    fetch(`https://my-api.com/todos/${todoId}`)
      .then(setTodo);
  }, [todoId])

  // Note that these two API endpoints don't receive `todoId`
  // because we assume it's the ID of the context
  function addComment(text) {
    fetch(`https://my-api.com/todos/${todoId}/comment`, {method: "POST", body: {text}})
  }

  function completeTodo() {
    fetch(`https://my-api.com/todos/${todoId}/complete`, {method: "POST"})
  }

  const api = {
    todo,
    completeTodo,
    addComment,
  }

  return <TodoContext.Provider value={api}>
    { // Also, we check that the todo exists
      // before rendering children that may assume the todo to exist
      todo !== null && children }
  </TodoContext.Provider>;
}

export const useToDoContext = () => useContext(TodoContext);

And then using it simplifies the rest of the app:

const App = () => (
  <TodoListGlobal>
    <Switch>
      <Route exact path='/todo/:todoId' component={ViewTodo} />
    </Switch>
  </TodoListGlobal>
);

Then we thread the context from the route param:

export const ViewTodo = () => {
  const { todoId } = useParams();

  // Note that neither ToDoDetailView nor ToDoCommentsView need to know
  // the todoId or how to fetch it or how to modify it.
  // The API is provided by ToDoContext
  return <ToDoContext todoId={todoId}>
    <ToDoDetailView />
    <AddToDoCommentView />
  </ToDoContext>;
}

And finally using it is quite simple:

export const ToDoDetailView = () => {
  // We don't care about ToDo ID, we view the details of the ToDo in context
  const { todo } = useToDoContext();

  return <>
    <h1>{todo.name}</h1>
    <ul>
      {todo.comments.map(comment => <li>{comment}</li>)}
    </ul>
  </>;
}

export const AddToDoCommentView = () => {
  // We don't care about ToDo ID, we add comments to the ToDo in context
  const { addComment } = useToDoContext();

  const [comment, setComment] = useState("");

  return <>
    <h2>Add comment</h2>
    <input type='text' value={comment} onChange={evt => setComment(evt.target.value)} />
    <button onClick={() => addComment(comment)}>Add comment</button>
  </>;
}

With this we don't have to thread the details of the ToDo ID to the components. AddToDoCommentView doesn't have to know how we end up posting the comment to the right ToDo, and that's not important, because that's the job of the API in context.

Notes

As you can see, the Context objects can be used to define a common piece of data to a subtree of the component tree, and we can use that to have localized "globals/singletons" that are not really singletons, and therefore can be composed. With this idea in mind, we can start improving our approach to constructing good frontend.

Back to index
Copyright © 2018-2023 Javier Casas - All rights reserved.