© Bohua Xu

useState

Jun 16, 2021 · 17min

useState - A Comprehensive Guide

The useState hook is one of the most fundamental hooks in React’s functional components. It allows you to add state management to your functional components without converting them to class components.

Basic Syntax

const [state, setState] = useState(initialValue)
const [state, setState] = useState(initialValue)
  • initialValue: The initial state value
  • state: The current state value
  • setState: A function to update the state value

Simple Example

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </>
  )
}
import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </>
  )
}

Updating State Based on Previous State

When updating state that depends on the previous state value, always use the functional form of the setState function:

// Correct way
setCount(prevCount => prevCount + 1)

// May lead to issues in certain scenarios
setCount(count + 1)
// Correct way
setCount(prevCount => prevCount + 1)

// May lead to issues in certain scenarios
setCount(count + 1)

Request Tracker Example

import { useState } from 'react'

export default function RequestTracker() {
  const [pending, setPending] = useState(0)
  const [completed, setCompleted] = useState(0)

  async function handleClick() {
    setPending(pending + 1) // Could be problematic in async contexts
    await delay(3000)
    setPending(pending => pending - 1) // Correct way - uses latest state
    setCompleted(completed + 1)
  }

  /* Important Note:
     Using setPending(pending - 1) would be incorrect here
     as it wouldn't access the latest state value after the async delay.
     Always use the functional form when updates depend on previous state in async operations.
  */

  return (
    <>
      <h3>
        Pending: {pending}
      </h3>
      <h3>
        Completed: {completed}
      </h3>
      <button onClick={handleClick}>
        Buy
      </button>
    </>
  )
}

function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}
import { useState } from 'react'

export default function RequestTracker() {
  const [pending, setPending] = useState(0)
  const [completed, setCompleted] = useState(0)

  async function handleClick() {
    setPending(pending + 1) // Could be problematic in async contexts
    await delay(3000)
    setPending(pending => pending - 1) // Correct way - uses latest state
    setCompleted(completed + 1)
  }

  /* Important Note:
     Using setPending(pending - 1) would be incorrect here
     as it wouldn't access the latest state value after the async delay.
     Always use the functional form when updates depend on previous state in async operations.
  */

  return (
    <>
      <h3>
        Pending: {pending}
      </h3>
      <h3>
        Completed: {completed}
      </h3>
      <button onClick={handleClick}>
        Buy
      </button>
    </>
  )
}

function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}

Using Objects with useState

You can store objects in state:

const [user, setUser] = useState({ name: 'John', age: 25 })

// To update a property
setUser(prevUser => ({ ...prevUser, age: 26 }))
const [user, setUser] = useState({ name: 'John', age: 25 })

// To update a property
setUser(prevUser => ({ ...prevUser, age: 26 }))

Using Arrays with useState

const [items, setItems] = useState([])

// Add item
setItems(prevItems => [...prevItems, newItem])

// Remove item
setItems(prevItems => prevItems.filter(item => item.id !== itemId))

// Update item
setItems(prevItems => prevItems.map(item =>
  item.id === itemId ? { ...item, name: newName } : item
))
const [items, setItems] = useState([])

// Add item
setItems(prevItems => [...prevItems, newItem])

// Remove item
setItems(prevItems => prevItems.filter(item => item.id !== itemId))

// Update item
setItems(prevItems => prevItems.map(item =>
  item.id === itemId ? { ...item, name: newName } : item
))

Lazy Initialization

For expensive initial state calculations, use a function:

const [state, setState] = useState(() => {
  const initialState = performExpensiveCalculation()
  return initialState
})
const [state, setState] = useState(() => {
  const initialState = performExpensiveCalculation()
  return initialState
})

Multiple State Variables vs. Single Object

You can choose between multiple state variables:

const [name, setName] = useState('John')
const [age, setAge] = useState(25)
const [name, setName] = useState('John')
const [age, setAge] = useState(25)

Or a single object:

const [user, setUser] = useState({ name: 'John', age: 25 })
const [user, setUser] = useState({ name: 'John', age: 25 })

Choose based on what state variables change together and your specific component needs.

Common Pitfalls

  1. Forgetting that setState is asynchronous:

    setCount(count + 1)
    console.log(count) // May still show the old value
    
    setCount(count + 1)
    console.log(count) // May still show the old value
    
  2. Direct mutation of objects or arrays:

    // Incorrect
    const newUser = user
    newUser.name = 'Jane'
    setUser(newUser) // This won't trigger a re-render
    
    // Correct
    setUser({ ...user, name: 'Jane' })
    
    // Incorrect
    const newUser = user
    newUser.name = 'Jane'
    setUser(newUser) // This won't trigger a re-render
    
    // Correct
    setUser({ ...user, name: 'Jane' })
    
  3. Not using the functional update form in event handlers with closures:

    function handleClick() {
      setTimeout(() => {
        // Incorrect - may use stale state
        setCount(count + 1)
    
        // Correct - always uses latest state
        setCount(prevCount => prevCount + 1)
      }, 1000)
    }
    
    function handleClick() {
      setTimeout(() => {
        // Incorrect - may use stale state
        setCount(count + 1)
    
        // Correct - always uses latest state
        setCount(prevCount => prevCount + 1)
      }, 1000)
    }
    

Best Practices

  1. Use multiple useState calls for unrelated state variables
  2. Use a single useState with an object for related state variables
  3. Always use the functional update form when new state depends on previous state
  4. Don’t call hooks conditionally or in loops
  5. Consider custom hooks for reusable stateful logic

The useState hook, while simple in concept, provides powerful state management capabilities for React functional components.

CC BY-NC-SA 4.0 2021-PRESENT © Bohua Xu