August 30, 2021

Prefer Function Updaters in State Setters

edit

This one will be short and sweet. You’re using React. You have some state.

const [selectedItem, setSelectedItem] = React.useState(null)

You’re already on board with the concepts in useEncapsulation and Prefer Declarative State Updaters, so you make a custom hook combining your state with the functions that will update that state.

function useItemSelection() {
  const [selectedItem, setSelectedItem] = React.useState(null)

  const handlers = React.useMemo(
    () => ({
      selectItem: item => {
        setSelectedItem(item)
      },
      unselectItem: () => {
        setSelectedItem(null)
      },
    }),
    [],
  )

  return [selectedItem, handlers]
}

Fantastic. Your selection logic is encapsulated in a nice, little custom hook. Your boss and colleagues love you and your code. All is well in the world!

Until…

You get some new requirements.

”We would love it if clicking on the currently selected item would unselect the item, rather than do nothing. Can you do that?”

Of course we can! But the question is how shall we do it? There are at minimum, two ways. Let’s do it the less-than-great way first.

In order to “unselect” an item, we need to know that the item we’re selecting is the same as current selectedItem. We could use the selectedItem that’s in scope.

function useItemSelection() {
  const [selectedItem, setSelectedItem] = React.useState(null)

  const handlers = React.useMemo(
    () => ({
      // ...same handlers as before

      smartSelectItem: item => {
        // Here we use `selectedItem` from the parent scope
        // We're _also_ assuming the "items" have an `id`
        // We will fix that later
        if (item?.id === selectedItem?.id) {
          setSelectedItem(null)
          return
        }

        setSelectedItem(item)
      },
    }),
    // Now we had to add `selectedItem` as a dependency
    // All handlers will update _every_ time `selectedItem` updates
    [selectedItem],
  )

  return [selectedItem, handlers]
}

We can pop this into a component and see that it works.

Current state: null

Great, this works as expected. Why is it less-than-great?

Because we’re relying on a dependency we don’t need to rely on, and in so doing, we’re forcing our state updaters to change unnecessarily.

If we use a “function updater” instead of relying on the selectedItem in scope, we can avoid the selectedItem dependency entirely.

const handlers = React.useMemo(() => {
  // ...same handlers as before, except...
  smartSelectItem: item => {
    setSelectedItem(currentItem => {
      if (item?.id === currentItem?.id) return null
      return item
    })
  }
}, [])

Now we no longer have a dependency because React is giving us all the information we need to determine the next state within the state setter itself. On top of that, we get a tidy little, almost pattern matching like function body. The fact that each branch of logic must return the nextState helps us in other ways. I’ve made the following bug a few times in my career:

{
  smartSelectItem: item => {
    if (item?.id === selectedItem?.id) {
      setSelectedItem(null)
    }

    setSelectedItem(item)
  }
}

See my mistake?

I forgot to return in the guard statement. Classic.

Other Benefits

Hooks solved a lot of problems, but they didn’t get rid of a few “gotchas”. In fact, useState created an extra gotcha you didn’t have to deal with in class component days. Let’s start there:

Object updates are not merged anymore

In the class component days, you could create a state like this:

this.state = {
  error: null,
  data: null,
  loading: false,
}

And you could do updates like this:

this.setState({ loading: true })

And your state would be:

console.log(this.state) // { error: null, data: null, loading: true }

But try this with a hook instead:

const [state, setState] = React.useState({
  error: null,
  data: null,
  loading: false,
})

setState({ loading: true })

console.log(state) // { loading: true }

What?! That’s right, objects aren’t merged. They are replaced.

I personally think this makes sense. By making the state setter more rudimentary, the API becomes straightforward. No longer do you need to know about the “behavior” of the state setter. It’s less information you need to store in memory, and I’m a big fan of that.

You can get around this using function updaters:

const [state, setState] = React.useState({
  error: null,
  data: null,
  loading: false,
})

setState(currentState => ({
  ...currentState,
  loading: true,
}))

console.log(state) // { error: null, data: null, loading true }

Calling state setters more than once in a handler body

This “gotcha” has also been true since the class component days. If you call a state setter more than once in the same function body, there’s a really good chance that the update will not be what you expect when that function body completes.

Here’s an absurd example, with a useTripleCounter hook:

function useTripleCount() {
  const [state, setState] = React.useState(0)

  const handlers = React.useMemo(
    () => ({
      inc: () => {
        setState(state + 1)
        setState(state + 1)
        setState(state + 1)
      },
      dec: () => {
        setState(state - 1)
        setState(state - 1)
        setState(state - 1)
      },
    }),
    [state],
  )

  return [state, handlers]
}

If I use this in a component, what’s going to happen? All three setStates are going to be called, utilizing the same state dependency and state will only change by a value of 1. Not to mention, our handlers update every single time state updates, which may lead to more rerenders than necessary.

But, if we swap those updates for function updaters:

const handlers = React.useMemo(
  () => ({
    inc: () => {
      // often I shorten `state` to just `s` when the update is this small
      setState(s => s + 1)
      setState(s => s + 1)
      setState(s => s + 1)
    },
    dec: () => {
      setState(s => s - 1)
      setState(s => s - 1)
      setState(s => s - 1)
    },
  }),
  [],
)

What happens now? Now our updates are all applied. Our count increments and decrements by 3. I hope you can see how this might be useful in a real situation.

Summary

Using function updaters leads to fewer gotchas and requires fewer dependencies than simply replacing the current state. Consider using them more often to improve your stateful React components.

Bonus: Improving the useItemSelection hook

I wanted to take a moment and make this hook a little better before finishing the post. This relates to my recent post on dependency injection which you should read.

Did you happen to notice another dependency in the useItemSelection hook? Here it is in full so you can look for it:

function useItemSelection() {
  const [selectedItem, setSelectedItem] = React.useState(null)

  const handlers = React.useMemo(
    () => ({
      selectItem: item => {
        setSelectedItem(item)
      },
      unselectItem: () => {
        setSelectedItem(null)
      },
      smartSelectItem: item => {
        setSelectedItem(currentItem => {
          if (item?.id === currentItem?.id) return null
          return item
        })
      },
    }),
    [],
  )

  return [selectedItem, handlers]
}

Hopefully you recognized that smartSelectItem knows a bit too much about the shape of the items being selected. What if these items are not objects? What if they are but don’t have ids, but some other key?

Let’s use dependency injection and a default parameter to fix this:

const tryId = x => x?.id

function useItemSelection(getKey = tryId) {
  const [selectedItem, setSelectedItem] = React.useState(null)

  const handlers = React.useMemo(
    () => ({
      selectItem: item => {
        setSelectedItem(item)
      },
      unselectItem: () => {
        setSelectedItem(null)
      },
      smartSelectItem: item => {
        setSelectedItem(currentItem => {
          if (getKey(item) === getKey(currentItem)) return null
          return item
        })
      },
    }),
    [getKey],
  )

  return [selectedItem, handlers]
}

Now useItemSelection is a bit more flexible. Perhaps you want to use name as a key instead:

const [state, handlers] = useItemSelection(x => x?.name)

No longer does useItemSelection force your data into a shape. You can easily conform useItemSelection to your data.


Liked the post?
Give the author a dopamine boost with a few "beard strokes". Click the beard up to 50 times to show your appreciation.
Need help with your software problems?

My team and I are ready to help you. Hire Agathist to build your next great project or to improve one of your existing ones.

Get in touch
Kyle Shevlin's face, which is mostly a beard with eyes

Kyle Shevlin is the founder & lead software engineer of Agathist, a software development firm with a mission to build good software with good people.

Agathist
Good software by good people.
Visit https://agath.ist to learn more
Sign up for my newsletter
Let's chat some more about TypeScript, React, and frontend web development. Unsubscribe at any time.
Logo for Array.reduce()
Array.reduce()
Check out my courses!
If you enjoy my posts, you might enjoy my courses, too. Click the button to view the course or go to Courses for more information.