Data is constantly changing inside your application. For example, clicking Next on an image carousel should change which image is displayed, selecting an item from a dropdown menu should filter the content, toggling a switch should enable/disable a feature, clicking the Add button on a counter should increment the displayed number.
1export default function Counter() {
2 let count = 0;
3 return (
4 <div>
5 <p>Count: {count}</p>
6 <button
7 onClick={() => {
8 count = count + 1;
9 }}>
10 Add
11 </button>
12 </div>
13 );
14}
For demonstration purposes, this is a counter app. At first, a variable count
is initialized, and every time the Add button is clicked, count
will increment by 1. The logic seems to be correct, but try this demo yourself and you'll find that it doesn't really work.
There are two problems preventing count
to be updated. First of all, changes to local variables won't trigger rerenders. Event though the value of count
is updated when the button is clicked, it will not be displayed.
And second, even if a new render is triggered, local variables don't persist between renders. A new count
variable will be created, and React won't be able to remember its previous value.
State is our solution to this conundrum. It is a way for React to remember certain things, and also triggers a rerender when the stored information changes.
Introducing useState, the first React hook
To create states, instead of defining the variable directly, you must use a function called useState()
.
1import { useState } from "react";
2
3export default function Counter() {
4 const [count, setCount] = useState(0);
5 return (
6 . . .
7 );
8}
useState()
is called a hook in React, and in this case, the hook returns two values, count
and setCount
. count
is the state variable and setCount
is a setter function for count
. useState(0)
tells React that the initial value of count
is 0.
Notice that the keyword const
is used to declare the state variable, which means its value cannot be altered directly. Instead, you must use the setter function setCount()
. This function will register a new render with React, and the component will be rerendered with the updated count
.
Let's fix our counter app to use state instead:
1export default function Counter() {
2 const [count, setCount] = useState(0);
3 return (
4 <div>
5 <p>Count: {count}</p>
6 <button
7 onClick={() => {
8 setCount(count + 1);
9 }}>
10 Add
11 </button>
12 </div>
13 );
14}
This time, when you click the Add button, the displayed number should increment by 1.
States are isolated and private
The states are local to each component instance. If you render a component twice, two separate copies of the state will be created, and they will not interfere each other. For example, let's create two counters:
components/Counter.jsx
1import { useState } from "react";
2
3export default function Counter() {
4 const [count, setCount] = useState(0);
5 return (
6 <div className="border rounded-md p-4 m-4">
7 <p>Count: {count}</p>
8 <button
9 onClick={() => {
10 setCount(count + 1);
11 }}>
12 Add
13 </button>
14 </div>
15 );
16}
App.jsx
1import Counter from "./components/Counter";
2
3export default function App() {
4 return (
5 <>
6 <Counter />
7 <Counter />
8 </>
9 );
10}
When you click the first Add button, only the first counter will be incremented, and the second one will not be affected.
States work just like regular variables in this regard. States defined inside a component is contained inside that component. They are not "visible" to the parent component, but can be optionally passed down to the child element as props.
Display.jsx
1export default function Display({ count }) {
2 return <p>Count: {count}</p>;
3}
Counter.jsx
1import Display from "./components/Display";
2
3export default function Counter() {
4 const [count, setCount] = useState(0);
5 return (
6 <div>
7 <Display count={count} />
8 <button
9 onClick={() => {
10 setCount(count + 1);
11 }}>
12 Add
13 </button>
14 </div>
15 );
16}
When the state changes, the component will be rerendered, along with its child component.
Objects and arrays in states
States can store data types such as numbers, strings, Boolean values, as well as complex data structures such as arrays and objects. However, when working with objects and arrays, you must remember to update the entire object/array instead of individual elements inside that object/array.
For example, here we have a list of products, each with buttons controlling the number in stock.
1export default function App() {
2 const [products, setProducts] = useState([
3 { id: 1, name: "Apple", count: 0 },
4 { id: 2, name: "Banana", count: 0 },
5 { id: 3, name: "Orange", count: 0 },
6 ]);
7
8 function handleAdd(id) {
9 const product = products.find((product) => product.id === id);
10 product.count += 1;
11 }
12
13 function handleRemove(id) {
14 const product = products.find((product) => product.id === id);
15 product.count -= 1;
16 }
17
18 return (
19 <div>
20 <h1>Product List</h1>
21 <ul>
22 {products.map((product) => (
23 <li key={product.id}>
24 {product.name} - {product.count}
25 <button onClick={() => handleAdd(product.id)}>Add</button>
26 <button onClick={() => handleRemove(product.id)}>Remove</button>
27 </li>
28 ))}
29 </ul>
30 </div>
31 );
32}
The idea is that when the Add or Remove buttons are clicked, the corresponding product count should change accordingly.
However, when you test this demo on your own computer, it is not going to work, because we are updating the count
item directly.
1product.count += 1;
A new render will not be triggered because the state setter setProducts()
is not used. To fix this problem, you should update the entire array like this:
1function handleAdd(id) {
2 setProducts(
3 products.map((product) =>
4 product.id === id
5 ? { id: product.id, name: product.name, count: product.count + 1 }
6 : product
7 )
8 );
9}
10
11function handleRemove(id) {
12 setProducts(
13 products.map((product) =>
14 product.id === id
15 ? { id: product.id, name: product.name, count: product.count - 1 }
16 : product
17 )
18 );
19}
This time, when the event handlers handleAdd()
and handleRemove()
are triggered, the entire array associated with products
will be replaced, and a rerender will be triggered.
This example can be further simplified using the spread syntax.
1function handleAdd(id) {
2 setProducts(
3 products.map((product) =>
4 product.id === id ? { ...product, count: product.count + 1 } : product
5 )
6 );
7}