State Management in React

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:

jsx
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.

jsx
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?

jsx
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:

jsx
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,

jsx
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 Displays 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 Panels, each comes with a header and a content section. Clicking on the header toggles the visibility of the content.

Panel.jsx

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 Panels together, you get an Accordion component. Each Panel can be individually opened by clicking its header.

Accordion.jsx

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 Panels 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

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

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.

State Management in React | TheDevSpace