Should I useState or useReducer?

Donations Make us online

Watch “When to useState instead of useReducer” on egghead.io

Watch “When to useReducer instead of useState” on egghead.io

Whenever there are two things to do the same thing, people inevitably ask: “When
do I use one over the other?” There are two possible reasons for having multiple
ways of doing the same thing:

  1. One is the “old way” and the other is the “new (and improved) way”. Typically
    the old way is kept around for backward compatibility reasons and the new way
    is the path forward for new code. For example: class components (old way) vs
    function components (new way).
  2. They come with different trade-offs that should be considered and therefore
    should be applied in situations that suit them better (sometimes meaning
    you’ll use more than one in a given application). For example:
    useEffect vs useLayoutEffect or
    Static vs Unit vs Integration vs E2E tests
    or
    “Control Props” vs “State Reducers”

useState and useReducer fall into the second category here. So let’s talk
about the trade-offs.

(and no, it’s not a
trick question
😂).

I think the best way to discuss these trade-offs is through the lens of
examples. We’ll look at two examples. One which suits useState better, and one
which suits useReducer better. This won’t be enough to cover all of the
trade-offs, but hopefully can be a good starting point for us.

Custom useDarkMode hook

I recently wrote this for my workshop projects
(for example). It’s pretty
interesting, so let’s compare useState and useReducer implementations for
that:

useState implementation

I’ll highlight the areas where we’re interacting with the state:

function useDarkMode() {
  const preferDarkQuery = '(prefers-color-scheme: dark)'
  const [mode, setMode] = React.useState(
    () =>
      window.localStorage.getItem('colorMode') ||
      (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
  )

  React.useEffect(() => {
    const mediaQuery = window.matchMedia(preferDarkQuery)
    const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
    mediaQuery.addListener(handleChange)
    return () => mediaQuery.removeListener(handleChange)
  }, [])

  React.useEffect(() => {
    window.localStorage.setItem('colorMode', mode)
  }, [mode])

  return [mode, setMode]
}

Hopefully that makes sense. Basically we’re saying, if the user’s set their
preferences to dark mode, then we’ll initialize our mode to dark, otherwise
we’ll initialize to light and then return the mode and setMode and as the
mode changes (whether by calling setMode directly or as the user changes their
system preferences) we’ll keep that value set in localStorage for future use.

useReducer implementation:

There are several ways you could write this with useReducer. I’ll start out
with the typical way most people write reducers:

const preferDarkQuery = '(prefers-color-scheme: dark)'

function darkModeReducer(state, action) {
  switch (action.type) {
    case 'MEDIA_CHANGE': {
      return {...state, mode: action.mode}
    }
    case 'SET_MODE': {
      // make sure to spread that state just in case!
      return {...state, mode: action.mode}
    }
    default: {
      // helps us avoid typos!
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

// use the init function to lazily initialize state so we don't read into
// localstorage or call matchMedia every render
function init() {
  return {
    mode:
      window.localStorage.getItem('colorMode') ||
      (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
  }
}

function useDarkMode() {
  const [state, dispatch] = React.useReducer(
    darkModeReducer,
    {mode: 'light'},
    init,
  )
  const {mode} = state

  React.useEffect(() => {
    const mediaQuery = window.matchMedia(preferDarkQuery)
    const handleChange = () =>
      dispatch({
        type: 'MEDIA_CHANGE',
        mode: mediaQuery.matches ? 'dark' : 'light',
      })
    mediaQuery.addListener(handleChange)
    return () => mediaQuery.removeListener(handleChange)
  }, [])

  React.useEffect(() => {
    window.localStorage.setItem('colorMode', mode)
  }, [mode])

  // We like the API the way it is, so instead of returning the state object
  // and the dispatch function, we'll return the `mode` property and we'll
  // create a setMode helper (which we have to memoize in case someone wants
  // to use it in a dependency list):
  const setMode = React.useCallback(
    newMode => dispatch({type: 'SET_MODE', mode: newMode}),
    [],
  )

  return [mode, setMode]
}

Wow, I think we can both agree that the useState version was WAY simpler! But
wait! We can drastically simplify the useReducer version by going against the
“grain” and not writing your typical redux-style reducer. Let’s try that:

function useDarkMode() {
  const preferDarkQuery = '(prefers-color-scheme: dark)'
  const [mode, setMode] = React.useReducer(
    (prevMode, nextMode) =>
      typeof nextMode === 'function' ? nextMode(prevMode) : nextMode,
    'light',
    () =>
      window.localStorage.getItem('colorMode') ||
      (window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
  )

  React.useEffect(() => {
    const mediaQuery = window.matchMedia(preferDarkQuery)
    const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
    mediaQuery.addListener(handleChange)
    return () => mediaQuery.removeListener(handleChange)
  }, [])

  React.useEffect(() => {
    window.localStorage.setItem('colorMode', mode)
  }, [mode])

  return [mode, setMode]
}

That’s a lot better than our other try with useReducer. But we basically
implemented useState with useReducer.
And even then, it’s still less clear than our useState version. So at the end
of the day, useState was a much better solution in this case.

When it’s just an independent element of state you’re managing: useState

Custom useUndo hook

There’s a great
useUndo hook on GitHub. I took
inspiration from that for this example. Thank you
Homer Chen!

(For these, almost everything is interacting with state, so… no highlights…)

useState implementation

function useUndo(initialPresent) {
  const [past, setPast] = React.useState([])
  const [present, setPresent] = React.useState(initialPresent)
  const [future, setFuture] = React.useState([])

  const canUndo = past.length !== 0
  const canRedo = future.length !== 0

  const undo = React.useCallback(() => {
    if (!canUndo) return

    const previous = past[past.length - 1]
    const newPast = past.slice(0, past.length - 1)

    setPast(newPast)
    setPresent(previous)
    setFuture([present, ...future])
  }, [canUndo, future, past, present])

  const redo = React.useCallback(() => {
    if (!canRedo) return

    const next = future[0]
    const newFuture = future.slice(1)

    setPast([...past, present])
    setPresent(next)
    setFuture(newFuture)
  }, [canRedo, future, past, present])

  const set = React.useCallback(
    newPresent => {
      if (newPresent === present) {
        return
      }
      setPast([...past, present])
      setPresent(newPresent)
      setFuture([])
    },
    [past, present],
  )

  const reset = React.useCallback(newPresent => {
    setPast([])
    setPresent(newPresent)
    setFuture([])
  }, [])

  return [
    {past, present, future},
    {set, reset, undo, redo, canUndo, canRedo},
  ]
}

Looks pretty ok right? It probably is, but there’s actually a situation where
this could be pretty buggy. But first, I want to address a common misconception
people have about calling multiple state updaters in sequence (like we’re doing
in each of those functions).

Often people think this means that you’ll trigger a re-render for each call (so,
they’re suggesting that calling reset would trigger three rerenders). First,
remember to focus on
Fixing the slow render before you fix the re-render,
but secondly, remember that React has a batch system so if you were to call
reset from an event handler or in a useEffect callback, it would trigger
only one re-render.

That said, if we were to call reset in an async function (like after making an
HTTP request), then that would result in three re-renders. However, in the
future with concurrent mode those will be batched as well. So my main concern
isn’t the re-renders.

My concern is with the insidious stale closure bugs in our code! Can you spot
them? There are three! I’ll give you a hint, there’s one in each of undo,
redo, and set, but there’s not one in reset.

Here’s a contrived example that would reveal this bug:

function Example() {
  const [state, {set}] = useUndo('first')

  React.useEffect(() => {
    set('second')
  }, [])

  React.useEffect(() => {
    set('third')
  }, [])

  return <pre>{JSON.stringify(state, null, 2)}</pre>
}

The printed result here would be:

{
  "past": ["first"],
  "present": "third",
  "future": []
}

It should be:

{
  "past": ["first", "second"],
  "present": "third",
  "future": []
}

So what happened to "second" in our situation? Ah! Turns out we’re missing our
dependency on set in the effect dependency array. Silly goose. Let’s add
those:

function Example() {
  const [state, {set}] = useUndo('first')

  React.useEffect(() => {
    set('second')
  }, [set])

  React.useEffect(() => {
    set('third')
  }, [set])

  return <pre>{JSON.stringify(state, null, 2)}</pre>
}

Great, save, reload… Wait wut? Oh no! Here’s what happened when I added those:

{
  "past": [
    "first",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "second",
    "third",
    "... this goes on forever..."
  ],
  "present": "third",
  "future": []
}

But wait! Aren’t we memoizing the set function? It shouldn’t change unless its
dependencies change… Wait… That includes the past and present values…
And whoops! When we call set those values are changed, which leads to our
infinite loop!

Now, this may seem contrived, but a similar bug could come up if you were
updating the state based on network events and they came back in a different
order from when they were sent out. Either way, you just don’t want to have to
think about this kind of thing right? Right.

So we can fix this problem with useReducer, but we can actually change our
useState implementation and side-step this issue and I thought you’d enjoy
that, so here it is:

function useUndo(initialPresent) {
  const [state, setState] = React.useState({
    past: [],
    present: initialPresent,
    future: [],
  })

  const canUndo = state.past.length !== 0
  const canRedo = state.future.length !== 0

  const undo = React.useCallback(() => {
    setState(currentState => {
      const {past, present, future} = currentState

      if (past.length === 0) return currentState

      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)

      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      }
    })
  }, [])

  const redo = React.useCallback(() => {
    setState(currentState => {
      const {past, present, future} = currentState

      if (future.length === 0) return currentState

      const next = future[0]
      const newFuture = future.slice(1)

      return {
        past: [...past, present],
        present: next,
        future: newFuture,
      }
    })
  }, [])

  const set = React.useCallback(newPresent => {
    setState(currentState => {
      const {present, past} = currentState
      if (newPresent === present) return currentState

      return {
        past: [...past, present],
        present: newPresent,
        future: [],
      }
    })
  }, [])

  const reset = React.useCallback(newPresent => {
    setState(() => ({
      past: [],
      present: newPresent,
      future: [],
    }))
  }, [])

  return [state, {set, reset, undo, redo, canUndo, canRedo}]
}

There are a few things I did to fix this issue:

  1. I used state updater callbacks in when calling the state updater functions so
    I could receive the currentState as an argument. This meant that I didn’t
    need to list the state as a dependency.
  2. I combined all the state into a single object. I had to do this because there
    are situations where you need one value to determine another. For example, in
    redo, I need the value of present to update past and the value of
    future to update present.
  3. I did the calculations for canUndo and canRedo within the state updater
    callbacks based on the currentState I receive from the arguments so I
    didn’t need to list those in the dependency array.

That solves the issue we were having and it does so pretty well, but let’s try
this same thing with useReducer to compare.

useReducer implementation

const UNDO = 'UNDO'
const REDO = 'REDO'
const SET = 'SET'
const RESET = 'RESET'

function undoReducer(state, action) {
  const {past, present, future} = state
  const {type, newPresent} = action

  switch (type) {
    case UNDO: {
      if (past.length === 0) return state

      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)

      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      }
    }

    case REDO: {
      if (future.length === 0) return state

      const next = future[0]
      const newFuture = future.slice(1)

      return {
        past: [...past, present],
        present: next,
        future: newFuture,
      }
    }

    case SET: {
      if (newPresent === present) return state

      return {
        past: [...past, present],
        present: newPresent,
        future: [],
      }
    }

    case RESET: {
      return {
        past: [],
        present: newPresent,
        future: [],
      }
    }
  }
}

function useUndo(initialPresent) {
  const [state, dispatch] = React.useReducer(undoReducer, {
    past: [],
    present: initialPresent,
    future: [],
  })

  const canUndo = state.past.length !== 0
  const canRedo = state.future.length !== 0
  const undo = React.useCallback(() => dispatch({type: UNDO}), [])
  const redo = React.useCallback(() => dispatch({type: REDO}), [])
  const set = React.useCallback(
    newPresent => dispatch({type: SET, newPresent}),
    [],
  )
  const reset = React.useCallback(
    newPresent => dispatch({type: RESET, newPresent}),
    [],
  )

  return [state, {set, reset, undo, redo, canUndo, canRedo}]
}

Wow, the useUndo thing itself is actually very simple now. If we had started
with useReducer from the get-go, we wouldn’t have even considered adding
anything to our dependency array because those functions are so simple they
don’t need anything. All the logic lives in our reducer. That helps us avoid the
issue naturally.

You may find it interesting that the switch cases in our reducer are basically
exactly what the contents of our functions were before we made the change.

When one element of your state relies on the value of another element of
your state in order to update: useReducer

So if you want some “rules” (NOT ESLINT RULES), here they are:

  • When it’s just an independent element of state you’re managing: useState
  • When one element of your state relies on the value of another element of
    your state in order to update: useReducer

Outside of these “rules,” everything else is really subjective. Honestly, even
the “rules” are subjective because as I demonstrated, you can do everything you
want with either one.

Also, please note that this applies on a case-by-case basis. You can absolutely
use useState in the same component or hook that’s using useReducer. And you
can have multiple useStates and multiple useReducers in a single hook or
component. That’s no problem. Separate state logically by domain. If it changes
together, it’s likely better to keep together in a reducer. If something is
pretty independent from other elements of state in that hook/component, then
putting it with other elements of state is just adding unnecessary complexity
and noise to that reducer and you’d be better off leaving it out on its own.

So it’s not just “when I have more than X number of useStates I switch to
useReducer.” It’s more nuanced than that. But hopefully this post helps you
understand those nuances and reach for the tool that has the trade-offs that
work best for your situation. In general, I suggest starting with useState,
and moving to useReducer when you notice elements of state need to change
together.

Good luck!




Source link

مدونة تقنية تركز على نصائح التدوين ، وتحسين محركات البحث ، ووسائل التواصل الاجتماعي ، وأدوات الهاتف المحمول ، ونصائح الكمبيوتر ، وأدلة إرشادية ونصائح عامة ونصائح