Skip to content

React Hooks - useState versus useReducer

Like many a React developer who preferred function components to classes, I was excited to begin implementing React Hooks upon their release in early 2019. Upon reflection of over a year spent consistently writing stateful function components, much has changed in my philosophy on how I approach such an implementation.

My first attempts were quite inelegant. I began building a component, and when I needed a state variable, I called useState. When I needed a second state variable, I called it again. And again. And again.

Let's take the example of a registration form with numerous controlled inputs and a modal that appears on a certain user action (maybe their password is invalid). Let's say we also have a toggle that allows the user to switch the form between light mode and dark mode. After a while, the top of such a component often begins to look like this:

import React, { useState } from 'react';
import Modal from 'some-modal-library';
import Toggle from '../Toggle';

const MyForm = () => {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [email, setEmail] = useState('');
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [modalVisible, setModalVisibility] = useState(false);
  const [darkMode, setDarkMode] = useState(false);
  
  return (
    <div className={darkMode ? 'dark' : 'light}>
     <Toggle onToggle={setDarkMode(!darkMode)} />
      <form>
        <label>First Name</label>
        <input type="text" onChange={e => setFirstName(e.target.value)} value={firstName} />
        // ...
      </form>
      <Modal open={modalVisible}>
        Password must contain at least one number and one letter
      </Modal>
    </div>
  )
}

export default MyForm;

"This is too many calls to useState; the top of my component is cluttered and repetitive," I would decide. "Time to convert this to use the useReducer hook." Although I often end up preferring the useReducer hook, my reasoning has evolved, and the false dichotomy I had created about being forced to choose between the two has shifted into a new and better understanding: often times, using both hooks in one component is the best answer.

Let's again look at our registration form with a modal. Armed with our philosophy of "combine multiple useState calls when we've exceeded an arbitrary number of them", we proceed with refactoring our component to no longer require useState.

import React, { useReducer } from 'react';
import Modal from 'some-modal-library';
import Toggle from '../Toggle';

const initialState = {
  firstName: '',
  lastName: '',
  email: '',
  username: '',
  password: '',
  modalVisible: false,
  darkMode: false
}

const reducer = (state, action) => {
  switch(action.type) {
    case 'set first name':
      return {
        ...state,
        firstName: action.payload
      };
    // ...  
    default: 
      return state;
  }
}

const MyForm = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  return (
    // ...
    <input 
      type="text" 
      onChange={
        e => dispatch({ type: 'set first name', payload: e.target.value })
      }
      value={state.firstName}
    />
    // ...
    <Modal open={state.modalVisible}>
    // ...

Okay; we refactored some things. Did we improve our component? By which metric did we improve or get worse? The answer becomes clearer when we begin to modify our component (or, better yet, when a teammate goes to modify it later).

A teammate is given this component and asked to add a Reset button to the form. If provided the first version, the result would likely look like this:

import React, { useState } from 'react';
import Modal from 'some-modal-library';

const MyForm = () => {
  // ...
  const onResetClick = () => {
    setFirstName('');
    setLastName('');
    setEmail('');
    setUsername('');
    setPassword('');
  }

  return (
    // ...
    <button onClick={onResetClick}>Reset</button>
    // ...
  )

That looks rather suboptimal. Wouldn't it be nice if we could batch those calls to set fields into a single command? Naturally, that's exactly what we do with the useReducer version of the component:

import React, { useReducer } from 'react';
import Modal from 'some-modal-library';
import Toggle from '../Toggle';

const initialState = {
  firstName: '',
  lastName: '',
  email: '',
  username: '',
  password: '',
  modalVisible: false,
  darkMode: false
}

const reducer = (state, action) => {
  switch(action.type) {
    // ...
    case 'reset form':
      return {
        ...initialState
      }
    // ...
  }
}

const MyForm = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  return (
    // ...
    <button onClick={() => dispatch({ type: 'reset form' })}>Reset</button>

Great! Thanks, teammate!

Except...

Now you've reset the form to light mode. :facepalm:

Perhaps I'm not giving this imaginary teammate enough credit. Perhaps they looked at initialState and saw that the darkMode boolean was included (not to mention the modal visibility boolean). In that case, they may have written their reducer solution to look like this:

// ...
const initialState = {
  firstName: '',
  lastName: '',
  email: '',
  username: '',
  password: '',
  modalVisible: false,
  darkMode: false
}

const reducer = (state, action) => {
  switch(action.type) {
    // ...
    case 'reset form':
      return {
        ...state,
        firstName: '',
        lastName: '',
        email: '',
        username: '',
        password: ''
      }
    // ...
  }
}

This brings us back to our question: did we make our component better when we moved every useState call into a single useReducer? I would posit that we have not. We've simply moved the verbosity from the top of the component to the state/reducer portion of our file. Additionally, we're mixing concerns. The form inputs' contents need not know the color scheme of the page and vice versa (nor is the visibility of the modal inherently tied to the form's values).

A clever developer might suggest this as an alternative:

// ...
const initialFormState = {
  firstName: '',
  lastName: '',
  email: '',
  username: '',
  password: '',
}

const initialNonFormState = {
  modalVisible: false,
  darkMode: false
}

const reducer = (state, action) => {
  switch(action.type) {
    // ...
    case 'reset form':
      return {
        ...state,
        ...initialFormState
      }
    // ...
  }

  // ...
  const [state, dispatch] = useReducer(reducer, { ...initialFormState, ...initialNonFormState })

Clever as this may be, we're still mixing concerns and not giving a person new to this component the best chance of understanding the functionality with the least amount of difficulty.

Let us finally arrive at what I believe to be the optimal solution: useState for singleton state variables combined with a useReducer for state variables that are associated with one another or dependent on one another in some way.

import React, { useState, useReducer } from 'react';
import Modal from 'some-modal-library';
import Toggle from '../Toggle';

const initialFormState = {
  firstName: '',
  lastName: '',
  email: '',
  username: '',
  password: ''
}

const reducer = (state, action) => {
  switch(action.type) {
    case 'set first name':
      return {
        ...state,
        firstName: action.payload
      };
    case 'reset form':
      return {
        ...initialFormState
      }
    // ...
    default: 
      return state;
  }
}

const MyForm = () => {
  const [formState, dispatch] = useReducer(reducer, initialFormState);
  const [darkMode, setDarkMode] = useState(false);
  const [modalVisible, setModalVisibilitiy] = useState(false);
  
  return (
    <div className={darkMode ? 'dark' : 'light}>
     <Toggle onToggle={setDarkMode(!darkMode)} />
      <form>
        <label>First Name</label>
        <input 
          type="text" 
          onChange={
            e => dispatch({ type: 'set first name', payload: e.target.value })
          }
          value={formState.firstName}
        />
        // ...
        <button onClick={() => dispatch({ type: 'reset form' })}>Reset</button>
      </form>
      <Modal open={modalVisible}>
        Password must contain at least one number and one letter
      </Modal>
    </div>
  )
}

export default MyForm;

Your average React developer who has worked with Hooks even a little will likely grasp this component and be able to add functionality or debug issues with much less difficulty than any of the previous examples (and less likelihood of adding new bugs themselves).

Do you agree? Disagree? Do you have an even better approach that I haven't discovered? Drop me a line. :)

Blog & Events

Featured Work