$ emrebener
home topics react usestate from basics to advanced: exploring the usestate hook in react

useState From Basics to Advanced: Exploring the useState Hook in React

author: emre bener read time: 4 min about: usestate, react
published: updated: mentions: react hooks, javascript, lazy initialization

State management is a core concept in React, and the useState hook is one of the most fundamental tools for managing state in functional components. With useState, you can add reactive, dynamic data to your components so the UI responds to user interactions and other changes. This post walks through how useState actually works, how to manage state efficiently, and a few pitfalls and optimizations worth knowing.

1. Definition

useState is a React Hook that lets you add a “state variable” to your components. This state variable holds data that can change over time, such as user input or UI theme preference.

When you declare a state using useState, the state gets initialized with a default value. The call returns a pair: the current state value and a function to update it (the “state setter function”). It’s a little awkward at first; the next sections make it concrete.

The key benefit is re-rendering. When a state variable changes, React re-renders the component so the UI reflects the new value (toggling between dark and light themes, say). That feedback loop is what makes useState the workhorse for dynamic data.

2. Reference

const [state, setState] = useState(initialValue)

The convention to declare state variables using array destructuring (const [something, setSomething]), because useState returns an array with exactly two values:

  1. A variable containing the current state value
  2. A setter function for updating the state value

You can provide a default value as a parameter in the useState call to initialize the state variable with a value.

3. Example

Let’s take a look at how the useState hook works in practice. In the following example, we create a simple React component that manages a piece of state called name:

import { useState } from 'react'  
  
function MyComponent() {  
  const [name, setName] = useState('Emre')  
  return (  
    <div>  
      <p>Hello, {name}!</p>  
      <input  
        type="text"  
        value={name}  
        onChange={(e) => setName(e.target.value)}  
      />  
    </div>  
  )  
}

In this example:

  • We declare a state variable name and a corresponding setter function setName using useState, initializing name with the default value 'Emre'.
  • The component renders an input field, allowing the user to change their name. The input’s value is controlled by the name state, and each time the user types in the input, setName is called to update the name state with the input’s new value.
  • Each time the name state changes, the component re-renders, updating the displayed name accordingly.

Every keystroke calls setName, which schedules a re-render, and React reconciles the input back to the new name value. That’s the whole loop.

4. Updating State Based on Previous Values

Whenever you’re modifying state based on its previous value (incrementing a counter, appending to a string, that kind of thing), pass an arrow function to the setter instead of a directly calculated value. Here’s why.

Assume you have this state variable:

const [count, setCount] = useState(0) // count is 0 by default

Now, let’s say you want to increment the count. Both of the following approaches would work:

setCount(count + 1) // valid  
setCount(prevCount => prevCount + 1) // also valid

The difference shows up when you call the setter more than once between renders:

setCount(count + 1)  
setCount(count + 1)  
// count becomes 1

In this case, the state would only be incremented by one. But if you use the arrow function approach:

setCount(prevCount => prevCount + 1)  
setCount(prevCount => prevCount + 1)  
// count becomes 2

The state will correctly increment by two.

Why Does This Happen?

It comes down to how React handles state updates. When you pass count + 1 directly to setCount, React reads the value of count captured at the time the function was called. If you call setCount several times before the next render, every call sees that same stale count and the state only moves by one.

Pass an arrow function and React queues the updates instead, running them in order. Each one receives the latest state value as prevCount, so the second call sees the result of the first. That’s the version that increments by two.

The arrow-function form is only necessary when you’re modifying state several times between renders based on its current value. If you only call the setter once before each render, a calculated value is fine.

5. Optimization Remarks

One performance footgun with useState is the default value. By default, it’s recomputed every time the component renders, which is fine for most things and a real problem for a few.

For example, with a state variable like this, there is no concern, as it’s just a simple value being passed as the default:

const [age, setAge] = useState(27)

However, with a state variable like the one below, you should be concerned about the potential performance impact:

const calculateFibonacci = (n) => {  
  if (n <= 1) {  
    return n  
  }  
  return calculateFibonacci(n - 1) + calculateFibonacci(n - 2)  
} // this will take a few seconds to compute  
  
const [fibValue, setFibValue] = useState(calculateFibonacci(40))

When that recomputation becomes a problem, switch to lazy initialization — pass a function instead of invoking one. The two forms look almost identical but behave very differently.

Direct Invocation

const [fibValue, setFibValue] = useState(calculateFibonacci(40))

Here, calculateFibonacci(40) runs every render because the argument to useState is the result of the call. React only uses that result on the first render; on every subsequent render it just discards it. The work is wasted.

Lazy Initialization (Passing a Function)

const [fibValue, setFibValue] = useState(() => calculateFibonacci(40))

Pass the function itself and React calls it once, on the initial render, when it sets up the state slot. The expensive work runs exactly when its result is needed and never again.

6. Managing an Object State

Most of the time you’ll manage individual pieces of information, each with its own useState call. Storing an object in a single state variable is a different story — updating it is where people get tripped up. Here’s the trap:

const [userPrefs, setUserPrefs] = useState({ theme: "dark", language: "eng" })  
  
// assume this function is used to change UI theme  
const switchTheme = (newTheme) => {  
  setUserPrefs({ theme: newTheme })  
}

This will update the theme but it wipes every other property the state variable was holding. React doesn’t merge objects when updating state; it replaces the old object with the new one wholesale. To keep the other properties, you have to merge them yourself:

const [userPrefs, setUserPrefs] = useState({ theme: "dark", language: "eng" })  
  
// assume this function is used to change UI theme  
const switchTheme = (newTheme) => {  
  setUserPrefs(prevState => {  
  return {...prevstate, theme: newTheme}  
  })  
}

Now the theme updates and the rest of the object survives.