Manage state with useReducer Hook 🤔

November 29, 2020

Managing State with useReducer

useReducer Hook

An alternative to useState. Accepts a reducer of type (state, action) => newState, and returns the current state paired with a dispatch method. (If you’re familiar with Redux, you already know how this works.)

Although useState is a Basic Hook and useReducer is an Additional Hook, useState is actually implemented with useReducer. This means useReducer is primitive and you can use useReducer for everything you can do with useState. Reducer is so powerful that it can apply for various use cases. The rest of this tutorial consists of various examples. Each example shows a certain use case and we show working code. Example01: Minimal pattern Let’s look at the simplest example code. We mostly use the counter example throughout this tutorial.

const initialState = 0
const reducer = (state, action) => {
  switch (action) {
    case 'increment':
      return state + 1
    case 'decrement':
      return state - 1
    case 'reset':
      return 0
    default:
      throw new Error('Unexpected action')
  }
}

We first define an initialState and a reducer. Note that the state here is a number, not an object. Redux users might get confused, but this is just fine. Furthermore, the action is a plain string here. The following is a component with useReducer.

const Example01 = () => {
  const [count, dispatch] = useReducer(reducer, initialState)
  return (
    <div>
      {count}
      <button onClick={() => dispatch('increment')}>+1</button>
      <button onClick={() => dispatch('decrement')}>-1</button>
      <button onClick={() => dispatch('reset')}>reset</button>
    </div>
  )
}

When a user clicks a button, it will dispatch an action which updates the count and the updated count will be displayed. You could define as many actions as possible in the reducer, but the limitation of this pattern is that actions are finite. The full working code can be found below:

App.js

Second way: Action object

👉 This example is the one that is familiar to Redux users. We use a state object and an action object.

const initialState = {
  count1: 0,
  count2: 0,
}
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment1':
      return { ...state, count1: state.count1 + 1 }
    case 'decrement1':
      return { ...state, count1: state.count1 - 1 }
    case 'set1':
      return { ...state, count1: action.count }
    case 'increment2':
      return { ...state, count2: state.count2 + 1 }
    case 'decrement2':
      return { ...state, count2: state.count2 - 1 }
    case 'set2':
      return { ...state, count2: action.count }
    default:
      throw new Error('Unexpected action')
  }
}

In this example, we keep two numbers in a state. We could use a complex object for a state as long as we organize a reducer well (ref: combineReducers). Because the action in this example is an object, we can put values like action.count in addition to a type. The reducer in this example is a bit of mess, but this allows us to simplify the component as the following.

const Example02 = () => {
  const [state, dispatch] = useReducer(reducer, initialState)
  return (
    <>
      <div>
        {state.count1}
        <button onClick={() => dispatch({ type: 'increment1' })}>+1</button>
        <button onClick={() => dispatch({ type: 'decrement1' })}>-1</button>
        <button onClick={() => dispatch({ type: 'set1', count: 0 })}>
          reset
        </button>
      </div>
      <div>
        {state.count2}
        <button onClick={() => dispatch({ type: 'increment2' })}>+1</button>
        <button onClick={() => dispatch({ type: 'decrement2' })}>-1</button>
        <button onClick={() => dispatch({ type: 'set2', count: 0 })}>
          reset
        </button>
      </div>
    </>
  )
}

Notice there are two counters in a state, and action types are defined to update one counter out of the two. See the full working code below:

Third way: Multiple useReducers

The previous example has two counters with a single state, which is a typical approach for global state. Because we are only working with local state, there is another way. We can use useReducer twice. Let’s look at the reducer.

const initialState = 0
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment':
      return state + 1
    case 'decrement':
      return state - 1
    case 'set':
      return action.count
    default:
      throw new Error('Unexpected action')
  }
}

The state here is a simple number instead of an object, which is the same in Example01. Note that the action here is an object, which is different from that in Example01. The component using this reducer will be the following.

const Example03 = () => {
  const [count1, dispatch1] = useReducer(reducer, initialState)
  const [count2, dispatch2] = useReducer(reducer, initialState)
  return (
    <>
      <div>
        {count1}
        <button onClick={() => dispatch1({ type: 'increment' })}>+1</button>
        <button onClick={() => dispatch1({ type: 'decrement' })}>-1</button>
        <button onClick={() => dispatch1({ type: 'set', count: 0 })}>
          reset
        </button>
      </div>
      <div>
        {count2}
        <button onClick={() => dispatch2({ type: 'increment' })}>+1</button>
        <button onClick={() => dispatch2({ type: 'decrement' })}>-1</button>
        <button onClick={() => dispatch2({ type: 'set', count: 0 })}>
          reset
        </button>
      </div>
    </>
  )
}

As you can see, we have two dispatch functions for each counter. We share the same reducer function for both. The functionality is identical to that of Example02. The full working code is below.

Fourth way: TextInput

Let’s look at a realistic example in which multiple useReducers work well. Suppose we have a React Native-like TextInput component, and we want to store text in local state. We can use a dispatch function to update the text.

const initialState = ''
const reducer = (state, action) => action

Note that the old state is just thrown away each time the reducer is called. The component using this is the following.

const Example04 = () => {
  const [firstName, changeFirstName] = useReducer(reducer, initialState)
  const [lastName, changeLastName] = useReducer(reducer, initialState)
  return (
    <>
      <div>
        First Name:
        <TextInput value={firstName} onChangeText={changeFirstName} />
      </div>
      <div>
        Last Name:
        <TextInput value={lastName} onChangeText={changeLastName} />
      </div>
    </>
  )
}

How simple it is. You could add some validation logic in reducer too. See the full example code below.

Fifth way: Context

At some point, we might want to share state between components a.k.a global state. In general, global state tends to limit component reusability, hence first consider using local state and only passing them (incl. dispatch) by props. When it doesn’t work well, Context is a rescue. If you are not familiar with Context API, check out the official document and how to use useContext. In this example, we use the same reducer in Example03. The following is the code on how to create a context.

const CountContext = React.createContext()

const CountProvider = ({ children }) => {
  const contextValue = useReducer(reducer, initialState)
  return (
    <CountContext.Provider value={contextValue}>
      {children}
    </CountContext.Provider>
  )
}

const useCount = () => {
  const contextValue = useContext(CountContext)
  return contextValue
}

The function useCount is called custom hooks which can be used just like normal hooks. More about custom hooks,read the official document. The component code is the following with useCount.

const Counter = () => {
  const [count, dispatch] = useCount()
  return (
    <div>
      {count}
      <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
      <button onClick={() => dispatch({ type: 'set', count: 0 })}>reset</button>
    </div>
  )
}

As our contextValue is just the result of useReducer, we destructure the result of useCount in the same way. Note that, at this point, it’s uncertain which context is used. Finally, here’s the code to use it.

const Way5 = () => (
  <>
    <CountProvider>
      <Counter />
      <Counter />
    </CountProvider>
    <CountProvider>
      <Counter />
      <Counter />
    </CountProvider>
  </>
)

We have two CountProviders here. It means there are two counters, even though we have only one context. The Counters inside the same CountProvider shares the state. You might need to learn how this works by running the example code and trying it. The full working code is below.

Sixth Way: Subscription

🛑 Context is the preferred way to share state among components, but what if we already have a shared state outside of React components. We can technically subscribe to such a shared state and update components when the shared state is updated. This pattern has limitations and React team provides a utility package: create-subscription. Unfortunately, the utility package is not yet for React Hooks as of writing, so we do our best with hooks for now. Let’s try to reproduce the same functionality of Example05 without Context. First, here’s a tiny custom hook to be used.

const useForceUpdate = () => useReducer((state) => !state, false)[1]

This reducer is simply to invert the previous state, ignoring the action. [1] is to return dispatch without destructuring. Next up is the main function to create a shared state and returns a custom hook.

const createSharedState = (reducer, initialState) => {
  const subscribers = []
  let state = initialState
  const dispatch = (action) => {
    state = reducer(state, action)
    subscribers.forEach((callback) => callback())
  }
  const useSharedState = () => {
    const forceUpdate = useForceUpdate()
    useEffect(() => {
      const callback = () => forceUpdate()
      subscribers.push(callback)
      callback() // in case it's already updated
      const cleanup = () => {
        const index = subscribers.indexOf(callback)
        subscribers.splice(index, 1)
      }
      return cleanup
    }, [])
    return [state, dispatch]
  }
  return useSharedState
}

We use a new useEffect hook. It’s a very important hook, and you should carefully read the official document to learn how it works. In useEffect, we subscribe a callback to force update the component. We also clean up the subscription when the component is unmounted. Let us create two shared states. We use the same reducer and initialState in Example05 and Example03.

const useCount1 = createSharedState(reducer, initialState)
const useCount2 = createSharedState(reducer, initialState)

Unlike useCount in Example05, these hooks are tied to specific shared states. We then use these two hooks.

const Counter = ({ count, dispatch }) => (
  <div>
    {count}
    <button onClick={() => dispatch({ type: 'increment' })}>+1</button>
    <button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
    <button onClick={() => dispatch({ type: 'set', count: 0 })}>reset</button>
  </div>
)

const Counter1 = () => {
  const [count, dispatch] = useCount1()
  return <Counter count={count} dispatch={dispatch} />
}

const Counter2 = () => {
  const [count, dispatch] = useCount2()
  return <Counter count={count} dispatch={dispatch} />
}

🛑 the Counter component is a stateless component in common. Finally, we use these components.

const Example06 = () => (
  <>
    <Counter1 />
    <Counter1 />
    <Counter2 />
    <Counter2 />
  </>
)

Up next