State is a way for React to remember a variable between renders, and when a state is updated, a new render will be registered.
Component with multiple states
In practice, things are usually more complicated. First of all, a component may need to work with multiple states. For example, let's add a theme switcher for our counter app:
1export default function App() {
2 const [count, setCount] = useState(0);
3
4 const [theme, setTheme] = useState("light");
5
6 function handleCountAdd() {
7 setCount(count + 1);
8 }
9
10 function handleThemeSwitch() {
11 theme === "light" ? setTheme("dark") : setTheme("light");
12 }
13
14 return (
15 <div
16 className={
17 theme === "light"
18 ? "border rounded-md p-4 m-4"
19 : "border rounded-md p-4 m-4 bg-gray-900 text-gray-100"
20 }>
21 <button onClick={handleThemeSwitch}>Switch Theme</button>
22
23 <p>Count: {count}</p>
24 <button onClick={handleCountAdd}>Add</button>
25 </div>
26 );
27}
In this example, count
remembers the counter value, and theme
remembers the theme. The event handler handleCountAdd()
updates count
, and handleThemeSwitch()
updates theme
.
Now, consider a more interesting scenario. What happens when a event handler function updates both states at the same time? For example, this counter counts how many times the theme has been toggled.
1export default function App() {
2 const [count, setCount] = useState(0);
3
4 const [theme, setTheme] = useState("light");
5
6 function handleThemeSwitch() {
7 theme === "light" ? setTheme("dark") : setTheme("light");
8 setCount(count + 1);
9 }
10
11 return (
12 <div
13 className={
14 theme === "light"
15 ? "border rounded-md p-4 m-4"
16 : "border rounded-md p-4 m-4 bg-gray-900 text-gray-100"
17 }>
18 <button onClick={handleThemeSwitch}>Switch Theme</button>
19
20 <p>Count: {count}</p>
21 </div>
22 );
23}
We discussed that when a state setter function is executed, the component will be rerendered. But in fact, this new render does not happen immediately.
When a state is updated, a rerender will be registered but not executed immediately. React will wait until the entire event handler function has been executed before triggering the new render. For instance, in the above example, when the event handler is triggered, React will wait until both setter functions to finish before rerender, allowing both states to be updated.
This example then leads to another interesting question, what happens when you update the same state multiple times in the event handler?
1export default function App() {
2 const [count, setCount] = useState(0);
3
4 function handleCountAdd() {
5 setCount(count + 1);
6 setCount(count + 1);
7 setCount(count + 1);
8 }
9
10 return (
11 <div className={"border rounded-md p-4 m-4"}>
12 <p>Count: {count}</p>
13 <button onClick={handleCountAdd}>Add</button>
14 </div>
15 );
16}
You might expect that clicking the Add button will increment the counter by 3, but in reality, it will only increment by 1. This is because even though states work a lot like regular variables in many aspects, they are not entirely the same.
In this case, the state value is fixed for each render, which means count
will be fixed to 1 each time setCount()
is executed, until it is updated in the next render.
This is a rare use case, but what if you do need to update the same state multiple times? This can be achieved by passing a callback function to the setter function like this:
1export default function App() {
2 const [count, setCount] = useState(0);
3
4 function handleCountAdd() {
5 setCount((n) => {
6 return n + 1;
7 });
8 setCount((n) => {
9 return n + 1;
10 });
11 setCount((n) => {
12 return n + 1;
13 });
14 }
15
16 return (
17 <div className={"border rounded-md p-4 m-4"}>
18 <p>Count: {count}</p>
19 <button onClick={handleCountAdd}>Add</button>
20 </div>
21 );
22}
This function tells React to do something to the state value instead of just replacing it. The functions will be queued and then executed one by one before the next render is displayed. And this time, when you click Add, the counter will increment by 3.
State shared between components
Besides having a component with multiple states, it is also possible for a state to be shared between multiple components by passing it down to multiple child components. For example,
1import { useState } from "react";
2
3function Display({ count }) {
4 return <p>Count: {count}</p>;
5}
6
7export default function Counter() {
8 const [count, setCount] = useState(0);
9 return (
10 <div className="border rounded-md p-4 m-4">
11 <Display count={count} />
12 <Display count={count} />
13 <button
14 onClick={() => {
15 setCount(count + 1);
16 }}>
17 Add
18 </button>
19 </div>
20 );
21}
The state count
is passed to the child component Display
. When the Add button is clicked, both Display
s will be updated.
This technique is useful when creating user interfaces where multiple components change at the same time. For example, consider how you can create an accordion.
It can be treated as a collection of Panel
s, each comes with a header and a content section. Clicking on the header toggles the visibility of the content.
Panel.jsx
1import { useState } from "react";
2
3export default function Panel({ header, children }) {
4 const [isActive, setIsActive] = useState(false);
5
6 return (
7 <div className="p-4 m-4 border rounded-md">
8 <div
9 onClick={() => {
10 setIsActive(!isActive);
11 }}>
12 {header}
13 </div>
14 {isActive && <div>{children}</div>}
15 </div>
16 );
17}
Combine multiple Panel
s together, you get an Accordion
component. Each Panel
can be individually opened by clicking its header.
Accordion.jsx
1import Panel from "./Panel";
2
3export default function Accordion() {
4 return (
5 <>
6 <Panel header={"Panel #1"}>Lorem ipsum dolor. . .</Panel>
7 <Panel header={"Panel #2"}>. . .</Panel>
8 <Panel header={"Panel #3"}>. . .</Panel>
9 <Panel header={"Panel #4"}>. . .</Panel>
10 <Panel header={"Panel #5"}>. . .</Panel>
11 </>
12 );
13}
But notice that opening a new Panel
doesn't automatically close the previously opened one. This is because the visibility is controlled by the state isActive
, which is isolated in each individual Panel
, and will not be shared between different components.
We need to create a structure where two Panel
s change at the same time, one opens while the other one closes. To do this, you need to lift the state up to their common parent, which is Accordion
.
Panel.jsx
1export default function Panel({ isOpen, handleHeaderClick, header, children }) {
2 return (
3 <div className="p-4 m-4 border rounded-md">
4 <div onClick={handleHeaderClick}>{header}</div>
5 {isOpen && <div>{children}</div>}
6 </div>
7 );
8}
Accordion.jsx
1import { useState } from "react";
2import Panel from "./Panel";
3
4export default function Accordion() {
5 const [openIndex, setOpenIndex] = useState(0);
6 return (
7 <>
8 <Panel
9 header={"Panel #1"}
10 isOpen={openIndex === 1}
11 handleHeaderClick={() => {
12 setOpenIndex(1);
13 }}>
14 Lorem ipsum dolor. . .
15 </Panel>
16 <Panel
17 header={"Panel #2"}
18 isOpen={openIndex === 2}
19 handleHeaderClick={() => {
20 setOpenIndex(2);
21 }}>
22 . . .
23 </Panel>
24 </>
25 );
26}
In this case, the state openIndex
controls which Panel will be opened.
When openIndex
is 0, openIndex === 0
gives true
, which is then passed to Panel
as the prop isOpen
.
The setter function setOpenIndex
is passed down to Panel
as an event handler handleHeaderClick
. When invoked, openIndex
will be set to the specified number.