useState - Direct value or callback function?

2023-01-154 min
useState - Direct value or callback function?

In this article:

  • What is useState hook
  • useState usage
  • Differences between passing direct value and a callback function
  • Possible issues with asynchronous code
  • Conclusion

A word of introduction about useState

The useState hook is a powerful way to add state to functional components in React. It allows you to store and manage state values within a component, and it provides a simple API for updating the state.

The useState hook takes an initial state value as an argument and returns an array with two elements: the current state value and a function that can be used to update the state. You can use the state value to affect the render of the component's UI, and you can use the update function to change the state and trigger a re-render of the component.

const [count, setCount] = React.useState(0)

As you can see in the code snippet above, the first element of the returned array is a value, and the second is a state dispatch function.

The useState usage

There are two possible ways to update the state with a useState. First, simpler is about passing a direct value as an argument to the state dispatch function.

const [count, setCount] = React.useState(0)
const increase = () => setCount(count + 1) // Pass direct value as an argument (count +1)

The second method is about passing a callback function as an argument. The callback function has an access to the previous state value, so you can use it to update the current state.

const [count, setCount] = React.useState(0)
const increase = () => setCount((previousValue) => previousValue + 1) // The setCount argument is a callback function

The second method is a little bit more complex but gives you different results in some cases, which I'll describe below.

useState differences and possible issues

Another difference between using a direct value and a callback within the useState is the way that the updates are processed. When you pass a direct value to useState, the value is kept in a closure and it's fixed throughout the execution of the function

On the other hand, when we pass a callback function to useState, we have an access to the previous value, which is not fixed by the closure.

So how does it affect the results?

Let's take a look at some examples

Example #1 setState inside of the interval

Below you can find a small example component that has:

  • directCount state
  • callbackCount state

Both, setDirectCount and setCallbackCount are functions that invoke state update dispatch and have a console log, so you can see the state value during function execution.

Besides that, I added one more log to see the value during the re-render of the component.

In the end, we have an interval added on the component mount. The interval triggers both setDirectCount and setCallbackCount on each loop.

import React from "react"

export const Example = () => {
  const [directCount, setDirectCount] = React.useState(0)
  const [callbackCount, setCallbackCount] = React.useState(0)

  const increaseDirect = () => {
    setDirectCount(directCount + 1)
    console.log('increaseDirect', directCount)
  }
  const increaseCallback = () => {
    setCallbackCount((previousValue) => previousValue + 1)
    console.log('increaseCallback', callbackCount)
  }

  console.log('Re-render log', {
    directCount,
    callbackCount
  })

  React.useEffect(() => {
    window.setInterval(() => {
      increaseDirect()
      increaseCallback()
    }, 2000)
  }, [])

  return <div>{directCount}</div>
}

Below you can see the results:

useState-closure.png

As you can see in the images above, we have the same state value kept in a closure for each loop. That's why the direct value passed to the state dispatch function does not increase the state during intervals. It always do 0 + 1 operation.

asynchronous-operation.png

On the other hand, you can see that the callback function passed as an argument to the state dispatch function has access to the previous value which is up to date for each interval loop. That's why we can increase the count on each function invocation, even if the callbackCount variable is kept in the closure with a value of 0.

Example #2 The asynchronous button onClick function vs the state update

import React from "react"

export const Example = () => {
  const [directCount, setDirectCount] = React.useState(0)
  const [callbackCount, setCallbackCount] = React.useState(0)

  const increaseDirect = () => {
    setDirectCount(directCount + 1)
  }
  const increaseCallback = () => {
    setCallbackCount((previousValue) => previousValue + 1)
  }

  console.log('Re-render log', {
    directCount,
    callbackCount
  })

  const iAmAsyncOperation = async () => {
    await new Promise((resolve, reject) => {
      setTimeout(() => {
        console.log('I am done')
        resolve('resolved')
      }, 3000)
    })
  }

  const handleClick = async () => {
    await iAmAsyncOperation()
    increaseDirect()
    increaseCallback()
  }

  return <div>
    <div>
      <div>direct: {directCount}</div>
      <div>callback: {callbackCount}</div>
    </div>
    <button onClick={handleClick}>click me!</button>
  </div>
}

In the code snippet above we can see an example with a button that has an async function added to the click event. Imagine that we clicked the button 5 times immediately one after another.

button-example.png button-ui.png

The pictures above show what happened

  • Direct value changed only once, from 0 to 1, because for a whole time, the directCount value was the same, kept in closure.
  • The callback function has an access to the previous value which is not kept in the closure, so it increases on each click. This way we're not dependent on the stale callbackValue value.

Example #3 Toggle vs setting state with a callback function

In this example, I'd like to show you that in some cases we should be aware of the previous state from the callback. In the code snippet below we have an isOpen state variable and the toggle function which is asynchronous. This function waits for the promise to be resolved and then invokes a state update function. Let's assume that we want to toggle the box just after an async operation resolves. We will do it by updating the state using the setIsOpen function and passing a callback as an argument.

import React from "react"

export const Example = () => {
  const [isOpen, setIsOpen] = React.useState(true)

  const toggle = async () => {
    await iAmAsyncOperation()
    setIsOpen((prev) => {
      console.log({
        isOpen,
        callbackPreviousValue: prev
      })
      return !prev
    })
  }

  const iAmAsyncOperation = async () => {
    await new Promise((resolve, reject) => {
      setTimeout(() => {
        console.log('Asynchronous operation is finished now!')
        resolve('resolved')
      }, 3000)
    })
  }

  return (
    <div>
      {isOpen && (
        <div style={{
          padding: 50,
          background: 'aqua',
          marginBottom: 20,
        }}>
          I am open!
        </div>
      )}
      <button onClick={toggle}>Close!</button>
    </div>
  )
}

Now, imagine that we're clicking the "close" button 2 times in a row immediately.

What do you expect to see? The result is really interesting, just take a look!

We clicked twice on the button, but UI did not change until the iAmAsyncOperation function was resolved. Then the useState update function has been invoked, and then after the new state has been set, the UI got an update a few times in a row. This happened because our state updates went into the queue, and were fired that many times as we clicked the button. It caused the UI to blink and even worse, with the first click we closed the box, but the second click made it visible again. It happened because each click changed the boolean state to the opposite value.

Summary

In conclusion, the choice between using a direct value or a callback with useState depends on how you want to update the state and whether you need to base the updates on the previous state. Both approaches have their benefits and trade-offs, and the right choice for your component will depend on your specific needs and use case.

It's generally recommended to use a function with useState when you need to base the updates on the previous state because it helps ensure that the updates use fresh value and avoids potential race conditions. On the other hand, you should be aware of the desired result and takes into consideration how it works under the hood to predict the state update behavior. If you simply need to update the state with a new value and you do not rely on the previous state, you can use a direct value.

Thanks for reading.