DEV Community

loading...

Creating stores using React context, hooks, and Immer

ferdaber profile image Ferdy Budhidharma ・4 min read

When creating any medium-to-large React applications, it's useful to have a store to contain your core application data. We don't want to be loading in the same data from APIs in scattered components, and we don't want to have to deal with the prop-drilling problem (passing through props down multiple levels in the React tree).

There are many application data management solutions out there, with Redux and MobX being two of the most popular. In this article we'll be creating our own home-grown store management solution using React context, hooks, and Immer.

Immer is an awesome library that allows you to perform mutations on non-primitive data structures in JavaScript, while still preserving the old data. It does this by creating a "draft copy" of the data structure you want to edit, and crawls through it and creating ES6 proxies to trap any mutations you perform. Those mutations are then recorded and replayed against a deep copy of your original data structure.

To start things off, we will create two React contexts: one to contain the store data, and one to allow editing that data. We'll do this using React's createContext API:

const initialState = {
  /* whatever you want */
}

const StateContext = React.createContext(initialState)
const UpdateContext = React.createContext(null) // soon to be populated with an updater function

We can even be clever and have the UpdateContext provider have a default updater function that throws an error in development mode to ensure that we always have an enclosing provider:

function invariantUpdaterFn() {
  if (process.env.NODE_ENV === 'development') {
    throw new Error('Updater was called without an enclosing provider.')
  }
}
const UpdateContext = React.createContext(invariantUpdaterFn)

Next, we want to encapsulate the two contexts into a single provider, so that they're always paired with each other.

export function StoreProvider({ children }) {
  return (
    <UpdateContext.Provider>
      <StateContext.Provider>{children}</StateContext.Provider>
    </UpdateContext.Provider>
  )
}

But we actually want to add the values for our providers so that they can actually be updated! We can leverage a built-in hook for that:

export function StoreProvider({ children }) {
  const [state, setState] = React.useState(initialState)
  return (
    <UpdateContext.Provider value={setState}>
      <StateContext.Provider value={state}>{children}</StateContext.Provider>
    </UpdateContext.Provider>
  )
}

This approach would work for the simplest kind of updater function, where a consumer can just pass in an entirely new store state and the entire state will be replaced. We want something better though; we want to be able to leverage the functionality of Immer to be able to just edit the state, which gives the user the most power while also preserving the old state. To do that, we can use a reducer function instead, using React's useReducer hook:

import produce from 'immer'

export function StoreProvider({ children }) {
  const [state, updater] = React.useReducer(produce, initialState)
  return (
    <UpdateContext.Provider value={updater}>
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </UpdateContext.Provider>
  )
}

The useReducer hook takes a reducer function as its first parameter, and the initial state as the second parameter. The reducer function itself has a signature that takes the current state as its first parameter, and some kind of action for the second parameter.

The action itself can be anything (in canonical Redux it's a plain object with a type and a payload). In our case however, the action will be some updater function that takes a proxied copy of the state, and mutates it. Luckily for us, that's exactly the same function signature that Immer's produce function expects (because it's modeled as a reducer)! So we can just pass the produce function as-is to useReducer.

This completes the implementation of our provider, which implements the necessary logic to update our store's state. Now we need to provide a way for users to actually be able to grab the store state, as well as update it as necessary. We can create a custom hook for that!

export function useHook() {
  return [useContext(StateContext), useContext(UpdateContext)]
}

This custom hook will return a tuple that can be deconstructed into the state, and the updater function, much like the useState hook.

With our implementation complete, this would be how an application would use this (with our favorite example, the Todo app):

// store.js
import React from 'react'
import produce from 'immer'

// an array of todos, where a todo looks like this: 
// { id: string; title: string; isCompleted: boolean }
const initialTodos = []

const StateContext = React.createContext(initialTodos)
const UpdateContext = React.createContext(null)

export function TodosProvider({ children }) {
  const [todos, updateTodos] = React.useReducer(produce, initialTodos)
  return (
    <UpdateContext.Provider value={updateTodos}>
      <StateContext.Provider value={todos}>
        {children}
      </StateContext.Provider>
    </UpdateContext.Provider>
  )
}

export function useTodos() {
  return [React.useContext(StateContext), React.useContext(UpdateContext)]
}

// app.js
import { TodosProvider } from 'store'

export function App() {
  return (
    <TodosProvider>
      {/* ... some deep tree of components */}
    </TodosProvider>
  )
}

// todo-list.js
import { useTodos } from 'store'

export function TodoList() {
  const [todos, updateTodos] = useTodos()

  const completeTodo = id =>
    updateTodos(todos => {
      todos.find(todo => todo.id === id).isCompleted = true
    })

  const deleteTodo = id =>
    updateTodos(todos => {
      const todoIdxToDelete = todos.findIndex(todo => todo.id === id)
      todos.splice(todoIdxToDelete, 1)
    })

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <span>{todo.title}</span>
          <button>Complete</button>
          <button>Delete</button>
        </li>
      ))}
    </ul>
  )
}

It's that easy! Our logic for creating the store is so generic, that we can even wrap it up into our own createStore function:

// create-store.js
import React from 'react'
import produce from 'immer'

export function createStore(initialState) {
  const StateContext = React.createContext(initialState)
  const UpdateContext = React.createContext(null)

  function StoreProvider({ children }) {
    const [state, updateState] = React.useReducer(produce, initialState)
    return (
      <UpdateContext.Provider value={updateState}>
        <StateContext.Provider value={state}>
          {children}
        </StateContext.Provider>
      </UpdateContext.Provider>
    )
  }

  function useStore() {
    return [React.useContext(StateContext), React.useContext(UpdateContext)]
  }

  return { Provider: StoreProvider, useStore }
}

// app.js
import { createStore } from 'create-store'

const TodosStore = createStore([])

export const useTodos = TodosStore.useStore

export function App() {
  return <TodosStore.Provider>{/* ... */}</TodosStore.Provider>
}

// todo-list
import { useTodos } from 'app'

export function TodoList() {
  const [todos, updateTodos] = useTodos()
  /* ... */
}

This approach works very well for small applications, where the React tree is shallow and debugging won't take forever. However for larger applications or larger teams you probably want to use Redux as it enforces a specific style, and also allows you to debug actions better by inspecting the dev tools.

Discussion (6)

pic
Editor guide
Collapse
yamessays profile image
James Cameron

Good article, but it's a lot of "here's the code" and not much explanation to the reasoning.

Collapse
sergeyt profile image
Sergey Todyshev

Here is a followup on this post

Collapse
sergeyt profile image
Sergey Todyshev

Useful pattern to me. Thank you!

Collapse
favreleandro profile image
Leandro Favre • Edited

Hi, great article! How can I see the previous state? I understand you use immer to make an immutable state.

Collapse
ferdaber profile image
Ferdy Budhidharma Author

You unfortunately can't see the previous state since you're not using Redux. Two options: transform your store so that it keeps a history stack of previous states, or use hook up all of this to a Redux store, and have it publish out the state to Redux DevTools.

Collapse
obiwarn profile image
Sebastian Obentheuer

Whoa...this is very elegant. I love it. Thank you!