How to use the React useState hook
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
.
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 updatecount
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.
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.
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.
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.
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.
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.
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.