DEV Community

Cover image for useState vs useReducer
Dominik D
Dominik D

Posted on • Originally published at tkdodo.eu

useState vs useReducer

The question about which state management solution to use might be just as old as React itself (or maybe even older), and answers to it are manifold. To me, there is only one good answer, and it's the same answer I will give to every seemingly complex question:

It depends.

— TkDodo

It depends on the type of state. It depends on update frequency. It depends on scoping.

If you know me, you know I have strong preferences on what to do with server state. So lets keep that out of the picture and look at everything that's left:

Client State

Before hooks, there was only one way of managing client state locally: in class-based components with this.setState. The state had to be an object, and the update function accepted a partial version of it.

Hooks changed that in a fundamental way. Not only could you now also manage state in functional components, you got two different ways of doing so with useState and useReducer.

I think the way most people approached the switch from class based state management to hooks was to split up the object and go towards a single useState for each field:

Before:

class Names extends React.Component {
  state = {
    firstName: '',
    lastName: '',
  }

  render() {
    return (
      <div>
        <input
          value={this.state.firstName}
          onChange={(event) =>
            this.setState({ firstName: event.target.value })
          }
        />
        <input
          value={this.state.lastName}
          onChange={(event) =>
            this.setState({ lastName: event.target.value })
          }
        />
      </div>
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

After:

const Names = () => {
  const [firstName, setFirstName] = React.useState('')
  const [lastName, setLastName] = React.useState('')

  return (
    <div>
      <input
        value={firstName}
        onChange={(event) => setFirstName(event.target.value)}
      />
      <input
        value={lastName}
        onChange={(event) => setLastName(event.target.value)}
      />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This is pretty much the textbook example, and the split makes a lot of sense here. The two fields are pretty self-sufficient as they update on their own.

But this isn't always the case. Sometimes, you might have state that actually updates together. In those situations, I don't think it makes sense to split it up into multiple useStates.

One example that comes to mind is storing mouse coordinates (x/y). Using two useStates seems super weird for something that always updates together, so I would use a single state object here:

const App = () => {
  const [{ x, y }, setCoordinates] = React.useState({ x: 0, y: 0 })

  return (
    <button
      onClick={(event) => {
        setCoordinates({ x: event.screenX, y: event.screenY })
      }}
    >
      Click, {x} {y}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Form state

I think a single useState object also works fine for a simple generic form, where the structure might be different each time you're using it, and you only want to update one field at the time. You can't really have multiple useStates for that, so a rudimentary custom hook implementation could look something like this:

const useForm = <State extends Record<string, unknown>>(
  initialState: State
) => {
  const [values, setValues] = React.useState(initialState)
  const update = <Key extends keyof State>(name: Key, value: State[Key]) =>
    setValues((form) => ({ ...form, [name]: value }))

  return [values, update] as const
}
Enter fullscreen mode Exit fullscreen mode

So, for useState, to decide if I want to split state up or not, I go by the following rule:

State that updates together should live together.

Batching

Consider using a single state object over calling multiple useState setters in a row. React is very good at batching those state updates together in synchronous event handlers, but still struggles with batching in async functions. This will get better with Automatic Batching in React 18, but structuring your code in a way so that you can reason about what state belongs together will help with readability and maintainability in the long run, regardless of performance concerns.

useReducer

I believe useReducer is still heavily underused. The main thinking around useReducer seems to be that you only need it for "complex state". As I've written previously, it's pretty good for toggling state:

const [value, toggleValue] = React.useReducer(previous => !previous, true)

<button onClick={toggleValue}>Toggle</button>
Enter fullscreen mode Exit fullscreen mode

It's also an often used way to implement forceUpdate (which almost every global state manager needs to inform subscribers about state changes if that state is kept outside of React):

const forceUpdate = React.useReducer((state) => state + 1, 0)[1]
Enter fullscreen mode Exit fullscreen mode

None of these implementations are particularly complex, and I think it really shows the flexibility of useReducer. That being said, it also shines when you update multiple parts of your state from different "actions", e.g. when implementing a multi-step wizard. You might want to initialize the second step depending on data chosen in the first step, or you might want to discard data of the third step when going back to the second.

All these dependencies between parts of your state would need you to call setState multiple times in a row when you have independent useStates (one for each step), and it would also get quite messy if you'd had a single state object.

useReducer tips

When I'm using useReducer, I try to adhere to the redux style guide. It's a great writeup that I can totally recommend, and most of the points also translate very well to useReducer, for example:

event driven reducers

Working immutably and not having side effects in reducers are things that most people will adhere to automatically, because it's in line with what react itself needs you do.

Modelling actions as events is something that I really want to emphasize on, because it's one of the biggest advantages of reducers. By doing so, you can keep all your application logic inside the reducer instead of spread around various parts of the ui. This will not only make it easier to reason about state transitions, it will also make your logic super easy to test (really, pure functions are the easiest to test).

To illustrate the concept, let's have a quick look at the standard counter example:

const reducer = (state, action) => {
  // ✅ ui only dispatches events, logic is in the reducer
  switch (action) {
    case 'increment':
      return state + 1
    case 'decrement':
      return state - 1
  }
}

function App() {
  const [count, dispatch] = React.useReducer(reducer, 0)

  return (
    <div>
      Count: {count}
      <button onClick={() => dispatch('increment')}>Increment</button>
      <button onClick={() => dispatch('decrement')}>Decrement</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The logic is not very sophisticated (adding 1 or subtracting 1), but it's still logic. We can extend that to allow an upper / lower bound, or customize the amount of numbers to increase / decrease with each click.

All of that would happen inside the reducer. Compare that to an example where the reducer is "dumb" and just accepts the new number:

const reducer = (state, action) => {
  switch (action.payload) {
    // 🚨 dumb reducer that doesn't do anything, logic is in the ui
    case 'set':
      return action.value
  }
}

function App() {
  const [count, dispatch] = React.useReducer(reducer, 0)

  return (
    <div>
      Count: {count}
      <button onClick={() => dispatch({ type: 'set', value: count + 1 })}>
        Increment
      </button>
      <button onClick={() => dispatch({ type: 'set', value: count - 1 })}>
        Decrement
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This works the same, but is not as extensible as the previous example. So generally speaking, try to avoid actions that have set in their name.

passing props to reducers

Another great trait of reducers is that you can inline them, or closure over props. This comes in very handy if you need access to props or server state (e.g. coming from a useQuery hook) inside your reducer. Instead of "copying" these things into the reducer by using the state initializer, you can pass it to a function:

const reducer = (data) => (state, action) => {
  // ✅ you'll always have access to the latest
  // server state in here
}

function App() {
  const { data } = useQuery(key, queryFn)
  const [state, dispatch] = React.useReducer(reducer(data))
}
Enter fullscreen mode Exit fullscreen mode

This goes very well with the concept of separating server and client state, and it actually wouldn't work at all if you'd pass data as initialValue, because when the reducer first runs, data will be undefined (as we still need to fetch it first).

So you'd wind up creating effects that try to sync the state into the reducer, which can get you in all sorts of troubles with background updates.

Extending our event driven counter example where we fetch an amount parameter from an endpoint would work pretty well with this approach. And of course, I'd use a custom hook for that:

const reducer = (amount) => (state, action) => {
  switch (action) {
    case 'increment':
      return state + amount
    case 'decrement':
      return state - amount
  }
}

const useCounterState = () => {
  const { data } = useQuery(['amount'], fetchAmount)
  return React.useReducer(reducer(data ?? 1), 0)
}

function App() {
  const [count, dispatch] = useCounterState()

  return (
    <div>
      Count: {count}
      <button onClick={() => dispatch('increment')}>Increment</button>
      <button onClick={() => dispatch('decrement')}>Decrement</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Note how we didn't need to change anything in the ui at all because of the clear separation provided by the custom hook 🎉

Rule of thumb

In summary, my rule of thumb of when to use what would be:

  • if state updates independently - separate useStates
  • for state that updates together, or only one field at a time updates - a single useState object
  • for state where user interactions update different parts of the state - useReducer

That's it for today. Feel free to reach out to me on twitter
if you have any questions, or just leave a comment below ⬇️

Discussion (0)