nico.fyi
Published on

You don't need useState in React

Authors
  • avatar
    Name
    Nico Prananta
    Twitter
    @2co_p

In one of the pull requests I reviewed, I noticed a pattern that I've seen in many pull requests. A React component had multiple UI states such as loading, error, and success. The author used multiple useState hooks to manage these states, which resulted in code that is hard to read and error-prone, for example:

const MyComponent = () => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(false)
  const [success, setSuccess] = useState(false)

  return (
    <div>
      {loading && !error && !success && <p>Loading...</p>}
      {error && !loading && !success && <p>Error occurred</p>}
      {success && !loading && !error && <p>Operation completed successfully</p>}
    </div>
  )
}

These states are distinct from each other. When loading is true, the error and success states should be false. Using multiple useState hooks can cause unexpected behaviors, like accidentally setting two states to true simultaneously.

Instead, consider using the "finite state machine" (FSM) pattern. A FSM allows only a finite number of states. In the UI example above, a single useState can manage the current state more robustly and with less risk of error, as shown here:

import { useState } from 'react'

type State = 'loading' | 'error' | 'success'

const MyComponent = () => {
  const [state, setState] = useState<State>('loading')

  const handleClick = () => {
    setState('loading')
    // Simulate an async operation
    setTimeout(() => {
      setState('success')
    }, 2000)
  }

  return (
    <div>
      {state === 'loading' && <p>Loading...</p>}
      {state === 'error' && <p>Error occurred</p>}
      {state === 'success' && <p>Operation completed successfully</p>}
      <button onClick={handleClick}>Click me</button>
    </div>
  )
}

In some cases, such as when using Tanstack query to fetch data, useQuery eliminates the need for separate useState hooks for loading, error, and success states:

const MyComponent = () => {
  const { data, isLoading, error } = useQuery(...)

  if (isLoading) {
    return <p>Loading...</p>
  }

  if (error) {
    return <p>Error occurred</p>
  }

  return <p>Operation completed successfully {data}</p>
}

Let's consider another state called locked, indicating whether the user has unlocked the feature, based on a 403 status code from the server. Often developers might use useState and useEffect to manage this, which can add unnecessary complexity:

const MyComponent = () => {
  const [locked, setLocked] = useState(false)
  const { data, isLoading, error } = useQuery(...)

  useEffect(() => {
    if (error && error.status === 403) {
      setLocked(true)
    }
  }, [error])

  if (locked) {
    return <p>You are locked out</p>
  }
}

A better approach derives the locked state directly from the error:

const MyComponent = () => {
  const { data, isLoading, error } = useQuery(...)

  if (isLoading) {
    return <p>Loading...</p>
  }

  const locked = error?.status === 403

  if (locked) {
    return <p>You are locked out</p>
  }
}

This method avoids the need for additional state management with useState and useEffect.

When writing a React component, always consider whether useState and useEffect are necessary. Often, they are not.


By the way, I'm making a book about Pull Requests Best Practices. Check it out!