How to use the React useState hook

Learn how to manage local state in React components using the useState hook.
September 23, 2022React
12 min read

The React useState hook is one of the most fundamental hooks to know because of how often it is needed when building components.

What is state?

You might be asking, "What is state anyway?" Let's start by answering this question.

Sometimes, components need to remember things. Components remember things using an internal component-specific memory called state. State is basically just data that changes over time. State allows components to change what they display to the screen after a certain user interaction, for example.

Using the useState hook

When useState is used in a component, React will remember that component's local state between re-renders.

If the whole re-render thing seems confusing, keep in mind that a re-render is nothing more than re-running or re-invoking the function that represents our component. This is because React components are like JavaScript functions.

Let's take a look at an example of how we would define the useState hook in a component. Every time we use the useState hook, we want our component to have a good memory and remember something. In the example below, we want our Counter component to remember the numeric value of count.

Counter.tsx
import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  
  // ...
}

export { Counter };

The only argument passed to the useState hook is the initial value of the state variable, count. In this example, the initial value of count is set to 0 using useState(0).

With TypeScript, we can specify the type of the state variable when using useState. In this case, the type of the count state variable should be a number, which is expressed as useState<number>(0).

Every time the Counter component renders, the useState hook will provide us with an array containing two values.

  • The state variable (count) and its initial value.
  • The state setter function (setCount). We need it to update count and re-render our component.

The useState hook always returns a pair, such as const [message, setMessage]. The naming within the array can be anything we want. However, if we name the second variable in the array using the set prefix, it makes it easier to understand what it actually does.

How useState works

Let's complete our Counter component that we defined earlier by adding a button that displays the count value as its label. Let's have this button call an increment function when it's clicked on.

Counter.tsx
import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  
  const increment = () => setCount(count + 1);
  
  return <button onClick={increment}>{count}</button>
}

export { Counter };

Now, let's take a look at how the Counter component's data changes over time.

When the Counter component renders for the first time, useState will return [0, setCount]. This is because we passed 0 to useState as the initial value for count. The Counter component will now remember that 0 is the latest state value for count.

When we click on the button for the first time, the increment method will be called, and it will call setCount(count + 1) to update the state of count to 1. Remember that count is 0, so setCount(count + 1) is essentially just (0 + 1). The Counter component will now remember that count is 1. Calling setCount(count + 1) also triggers a component re-render. This will be the Counter component's second render.

During the second render, useState(0) is still executed, but thanks to our usage of useState, our Counter component remembers that we set count to 1. Thus, useState(0) returns [1, setCount] instead of the [0, setCount] that was from the first render.

If we keep clicking on the button, the incrementing of count will continue until the Counter component is unmounted.

The component can be unmounted if the user navigates away from the page, or if the user closes the browser tab running the application.

A local variable versus useState

Could we have used a local variable instead of useState in our Counter component? Let's find out.

If we try re-writing the Counter component above using a local variable instead of state, it would look something like this.

Counter.tsx
const Counter = () => {
  let count: number = 0;
  
  const increment = () => count + 1;
  
  return <button onClick={increment}>{count}</button>
}

export { Counter };

Clicking on the button does nothing in this example. A button with a label of 0 continues to be displayed on the screen even after it is clicked multiple times to increment count.

Unlike the useState hook, the local variable count does not trigger a component re-render with the newly incremented value. The local variable count also does not preserve its value between component re-renders. Every time that the Counter component re-renders, count will always start over at 0.

Only the value of useState variables persist across re-renders. Also, only when useState is used will the component re-render to show the current value of count.

State is changed only after a re-render

Let's take a look at the following example using the Counter component once again. If we try logging count to the console immediately after calling setCount, we might be surprised at the result.

Counter.tsx
import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  
  const increment = () => {
    setCount(count + 1);
    console.log(count);
  };
  
  return <button onClick={increment}>{count}</button>
}

export { Counter };

The first button click will log 0 to the console, while the button will display 1. The second button click will log 1 to the console, while the button will display 2, and so forth.

Rather than logging the count value after count + 1 happens, the previous value is logged. This is because calling the state's setter function, setCount, won't change the value for count until the Counter component re-renders. The state's setter function, setCount, only changes what the useState hook will return when the component re-renders

By logging to the console immediately after setCount is called, we are not waiting for the component to re-render to provide us with the updated count value. We are instead logging the count value that came from the the last component render's invocation of useState.

useState is asynchronous

State updates in React are asynchronous. The state's setter function does not actually perform an immediate state update. Instead, it schedules a state update for some unknown time in the future. This means that we can't find out exactly when a state update occurred or will occur, just by looking at the code.

The useState setter function accepts a new state value and enqueues a re-render of the component. What does it mean to enqueue a re-render? It means that it doesn't re-render the component immediately. useState is asynchronous for performance reasons. This is why state changes with useState don't feel immediate.

Updating state based on the previous state

There are times when it can be helpful to update the state using the previous state. Consider the following example.

Counter.tsx
import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  
  const increment = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };
  
  return <button onClick={increment}>{count}</button>
}

export { Counter };

After one click of the button, count will be 1 instead of 3, even if we called setCount three times. Surprising, right?

This is because, as we saw earlier, calling the state's setter function doesn't update the state variable in the already running code. It only update's the state after the component re-renders. Each call to setCount(count + 1) is just setCount(0 + 1), which is 1, three times in a row!

Instead of passing the next state within setCount, which is represented by count + 1, we can pass an updater function to setCount. Let's see what that would look like.

Counter.tsx
import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  
  const increment = () => {
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
  };
  
  return <button onClick={increment}>{count}</button>
}

export { Counter };

In this updated example, c => c + 1 is our updater function. It takes the pending state of count and calculates the next state of count from it. Where does this pending state come from, you might ask? React puts all our state updater functions in a queue. This queue creates the concept of a pending state.

A queue is a linear data structure. Think of it as a collection of items in which only the earliest added item can be accessed and removed.

When our Counter component re-renders, React will execute our updater functions from the queue in the same order that we defined them.

Based on the example above, here is how React will process our updater functions for setCount that are in the queue.

// receives 0 as the pending state and returns 1 as the next state
c => c + 1
// receives 1 as the pending state and returns 2 as the next state
c => c + 1
// receives 2 as the pending state and returns 3 as the next state
c => c + 1

Once React is done processing the queue containing our updater functions, it will store 3 as the current state for count.

It's common React convention to name the pending state argument using the first letter of the state variable's name, which is count. This is why we used c as the pending state argument in our updater function, c => c + 1.

Some may find it easier to name it as prevCount or previousCount, in order to better understand what it actually represents.

If the state that we are setting needs to be computed from the pending state, we should use the updater function approach outlined here.

Using setCount(c => c + 1) is a bit more verbose than setCount(count + 1) but it gives us access to the pending state when we need it. If the state that we are setting does not need to know about the pending changes to state, then we can opt for the less verbose approach, namely setCount(count + 1).

useState and child components

We learned that React triggers a component re-render whenever the setter function of the useState hook is called. This doesn't only mean that the component itself will re-render. It also means that all child components contained within this component will also re-render.

Child components will re-render when their parent component re-render, regardless of whether their props have changed or not.

To illustrate this, consider the following example where we split Counter into a CounterChanger parent component and a CounterDisplay child component.

CounterChanger.tsx
type Props = {
  counter: number;
};

const CounterDisplay = ({ counter }: Props) => {
  return (
    <p>{counter}</p>
  );
}

const CounterChanger = () => {
  const [value, setValue] = useState(0);

  return (
    <div>
      <CounterDisplay value={value} />
      <button onClick={() => setValue(Math.random())}>Change</button>
    </div>
  );
}

export { CounterChanger };

In the example above, the CounterDisplay component doesn't need to use any state. This is because it will automatically re-render when its parent component, CounterChanger re-renders.

The CounterChanger component re-renders every time the button is pressed. This is because each button press calls the value state's setter function, which updates value and re-renders the component to reflect the updated value. The updated value is passed along to the CounterDisplay component via its props, where it gets displayed to the screen.

We should always keep in mind that child components re-render whenever their parent component re-renders. This important detail must be considered when designing the component architecture for our applications. If we build a poorly structured component architecture or if we misuse component state, we could end up unnecessarily re-rendering many child components that don't actually need to be re-rendered.

When initial state is expensive

When the initialization of state using useState is computationally expensive, it needs to be initialized properly or it can cause performance issues. For more info, check out the article The useState hook and lazy initialization.

Conclusion

In this article, we gained a solid understanding of one of the most fundamental React hooks, the useState hook. By knowing what state is, how to manage state with the useState hook, and how to avoid common useState pitfalls, we can write better React components.

There is no limit to the amount of state variables that we can include in a component. We can use as many state variables of as many types as we need in one component.

To keep it simple, this article only provided examples that inserted numbers into state. However, we can also put strings, objects, and arrays into state.

When state is unrelated, consider splitting it up into separate useState variables. When state is related, multiple state variables will end up changing together. In such cases, it may be more convenient to combine these state variables into one object. For an example of this, check out the article Using the React useState hook for forms.

New
Be React Ready

Learn modern React with TypeScript.

Learn modern React development with TypeScript from my books React Ready and React Router Ready.