Episode 2 — React Frontend Architecture NextJS / 2.3 — State and Rerendering Logic

2.3.b — useState Hook

In one sentence: useState is React's built-in hook that gives a component a piece of state: a value that persists between renders and a function to update that value (triggering a re-render).

Navigation: ← 2.3.a — What Is State · Next → 2.3.c — How React Re-renders


Table of Contents

  1. Syntax and Basic Usage
  2. How useState Works Under the Hood
  3. Initial Values
  4. Updating State
  5. Functional Updates
  6. State with Different Data Types
  7. Updating Objects in State
  8. Updating Arrays in State
  9. Updating Nested State
  10. Multiple useState Calls
  11. Lazy Initialization
  12. Common Mistakes
  13. useState vs useReducer
  14. Debugging State with React DevTools
  15. Practical Examples
  16. Key Takeaways
  17. Explain-It Challenge

1. Syntax and Basic Usage

The useState hook follows a consistent pattern:

import { useState } from "react";

function MyComponent() {
  const [value, setValue] = useState(initialValue);
  //     ^^^^^  ^^^^^^^^           ^^^^^^^^^^^^
  //     |      |                  |
  //     |      |                  Initial value (used on first render only)
  //     |      Setter function (call this to update state and trigger re-render)
  //     Current state value (read this to use the state)
}

Anatomy of the useState Call

const [count, setCount] = useState(0);

Breaking this down:

  1. useState(0) -- calls the hook with initial value 0
  2. Returns an array with exactly two elements: [currentValue, setterFunction]
  3. Array destructuring assigns count (the value) and setCount (the setter)

Why Array Destructuring?

useState returns an array, not an object. This lets you name the variables whatever you want:

const [count, setCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [username, setUsername] = useState("");
const [items, setItems] = useState([]);

// Convention: [thing, setThing]

A Complete Example

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

What happens when the button is clicked:

1. User clicks button
2. onClick handler fires: setCount(0 + 1)
3. React queues a state update: count should become 1
4. React schedules a re-render of Counter
5. Counter() runs again
6. useState(0) returns [1, setCount] -- the 0 is ignored, stored value (1) is used
7. New JSX is returned with count = 1
8. React updates the DOM: "You clicked 1 times"

2. How useState Works Under the Hood

Understanding the internals helps you avoid bugs and write better code.

The Hook Linked List

React doesn't use the variable name to track which useState call maps to which state. It uses call order. Each component instance maintains an ordered list of hooks.

Component Instance: <Counter />
Hook List:
  [0] useState  -->  value: 5      (count)
  [1] useState  -->  value: true   (isOpen)
  [2] useEffect -->  cleanup: fn
  [3] useState  -->  value: "hello" (text)

On every render, React walks through this list in order. The first useState call gets slot 0, the second gets slot 1, etc.

Why Hooks Must Be Called in the Same Order

// BAD: Conditional hook call
function BadComponent({ showExtra }) {
  const [count, setCount] = useState(0);

  if (showExtra) {
    const [extra, setExtra] = useState(""); // VIOLATION!
  }

  const [name, setName] = useState("Alice");
}

When showExtra is true: Slot 0 = count, Slot 1 = extra, Slot 2 = name When showExtra is false: Slot 0 = count, Slot 1 = name (React thinks this is extra!)

This is why the Rules of Hooks exist:

  1. Only call hooks at the top level (not inside conditions, loops, or nested functions)
  2. Only call hooks from React function components or custom hooks

Fiber Architecture

Each component instance corresponds to a Fiber node. The Fiber stores:

Fiber Node:
+---------------------------+
| type: Counter             |
| stateNode: DOM element    |
| memoizedState: ---------> Hook 0 (count: 5)
|                              |
|                              next: -----> Hook 1 (isOpen: true)
|                                              |
|                                              next: -----> Hook 2 (...)
| pendingProps: {...}       |
| updateQueue: [...]        |
+---------------------------+

The memoizedState field points to the first hook, forming a linked list.

The Update Queue

When you call setCount(newValue), React creates an update object and adds it to a queue:

setCount(5)

Update Object:
{
  action: 5,
  next: null,
  lane: DefaultLane,
}

This gets added to the hook's update queue.
On next render, React processes the queue to compute the final state.

3. Initial Values

The argument to useState is only used on the first render. After that, React ignores it.

Primitive Initial Values

const [count, setCount] = useState(0);            // number
const [name, setName] = useState("");              // string
const [isVisible, setIsVisible] = useState(false); // boolean
const [selected, setSelected] = useState(null);    // null

Object and Array Initial Values

const [user, setUser] = useState({
  name: "",
  email: "",
  age: 0,
});

const [items, setItems] = useState([]);
const [position, setPosition] = useState({ x: 0, y: 0 });

Function Initial Values (Lazy Initialization)

If the initial value is expensive to compute, pass a function instead:

// BAD: This function runs on EVERY render, result thrown away after first
const [data, setData] = useState(expensiveCalculation());

// GOOD: This function runs only on the FIRST render
const [data, setData] = useState(() => expensiveCalculation());
useState(expensiveCalculation())
  --> expensiveCalculation() called immediately, every render
  --> Result used only on first render

useState(() => expensiveCalculation())
  --> Function reference passed to useState
  --> React calls it only on render 1
  --> No wasted computation on subsequent renders

Common Lazy Initialization Patterns

// Reading from localStorage
const [theme, setTheme] = useState(() => {
  const saved = localStorage.getItem("theme");
  return saved ? JSON.parse(saved) : "light";
});

// Expensive computation
const [grid, setGrid] = useState(() => {
  return Array.from({ length: 100 }, (_, row) =>
    Array.from({ length: 100 }, (_, col) => ({
      row,
      col,
      value: computeInitialValue(row, col),
    }))
  );
});

// Parsing URL parameters
const [filters, setFilters] = useState(() => {
  const params = new URLSearchParams(window.location.search);
  return {
    category: params.get("category") || "all",
    sort: params.get("sort") || "newest",
  };
});

4. Updating State

There are two ways to call the setter function.

Direct Value Update

const [count, setCount] = useState(0);

setCount(5);         // Set count to 5
setCount(count + 1); // Set count to current count + 1
setCount(0);         // Reset count to 0

Functional Update (Updater Function)

const [count, setCount] = useState(0);

setCount(prev => prev + 1);  // Increment based on previous value
setCount(prev => prev * 2);  // Double the previous value
setCount(prev => Math.max(prev - 1, 0)); // Decrement but don't go below 0

When to Use Which

ScenarioUse DirectUse Functional
Setting to a known valuesetCount(0)
Based on user inputsetName(e.target.value)
Based on previous statesetCount(prev => prev + 1)
Multiple updates in one handlerAlways
Inside setTimeout/setIntervalAlways
Toggling a booleansetIsOpen(prev => !prev)

The Stale Closure Problem

function Counter() {
  const [count, setCount] = useState(0);

  function handleTripleIncrement() {
    // BAD: Uses the closure value of count, which is 0 for all three
    setCount(count + 1); // 0 + 1 = 1
    setCount(count + 1); // 0 + 1 = 1
    setCount(count + 1); // 0 + 1 = 1
    // Result: count becomes 1, not 3

    // GOOD: Uses functional updates
    setCount(prev => prev + 1); // 0 + 1 = 1
    setCount(prev => prev + 1); // 1 + 1 = 2
    setCount(prev => prev + 1); // 2 + 1 = 3
    // Result: count becomes 3
  }
}

5. Functional Updates

Functional updates solve several real problems.

Problem 1: Multiple Updates in One Event Handler

Use prev => when you need multiple sequential updates (shown above).

Problem 2: Stale Closures in Timeouts

function Timer() {
  const [count, setCount] = useState(0);

  function startCounting() {
    // BAD: count is always 0 in this closure
    setInterval(() => {
      setCount(count + 1); // Always 0 + 1 = 1
    }, 1000);

    // GOOD: Functional update always uses the latest value
    setInterval(() => {
      setCount(prev => prev + 1);
    }, 1000);
  }
}

Problem 3: Event Handlers Created Once

function SearchResults() {
  const [results, setResults] = useState([]);

  const handleNewResult = useCallback((result) => {
    // BAD: results might be stale if this callback was memoized
    setResults([...results, result]);

    // GOOD: Always uses latest state
    setResults(prev => [...prev, result]);
  }, []);
}

The Rule of Thumb

If the new state depends on the old state, use functional updates. If you're setting state to a completely new independent value, direct updates are fine.

// Direct updates -- fine:
setName("Alice");
setCount(0);
setIsOpen(false);

// Functional updates -- needed:
setCount(prev => prev + 1);
setItems(prev => [...prev, item]);
setIsOpen(prev => !prev);

6. State with Different Data Types

Strings

function NameInput() {
  const [name, setName] = useState("");

  return (
    <input
      value={name}
      onChange={e => setName(e.target.value)}
      placeholder="Enter your name"
    />
  );
}

Numbers

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(prev => prev - 1)}>-</button>
      <span>{count}</span>
      <button onClick={() => setCount(prev => prev + 1)}>+</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

Booleans

function TogglePanel() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(prev => !prev)}>
        {isOpen ? "Close" : "Open"} Panel
      </button>
      {isOpen && <div className="panel">Panel content here</div>}
    </div>
  );
}

Null

function UserProfile() {
  const [user, setUser] = useState(null);

  if (user === null) {
    return <p>No user selected</p>;
  }

  return <p>Welcome, {user.name}</p>;
}

7. Updating Objects in State

Objects in state must be replaced, never mutated.

Updating a Single Field

const [person, setPerson] = useState({
  firstName: "Alice",
  lastName: "Smith",
  age: 30,
});

function updateFirstName(newName) {
  setPerson({ ...person, firstName: newName });
}

// With functional update
function updateAge(newAge) {
  setPerson(prev => ({ ...prev, age: newAge }));
}

Dynamic Field Updates

function handleChange(e) {
  const { name, value } = e.target;
  setFormData(prev => ({ ...prev, [name]: value }));
}

This pattern is common in forms:

function Form() {
  const [formData, setFormData] = useState({
    username: "",
    email: "",
    password: "",
  });

  function handleChange(e) {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  }

  return (
    <form>
      <input name="username" value={formData.username} onChange={handleChange} />
      <input name="email" value={formData.email} onChange={handleChange} />
      <input name="password" value={formData.password} onChange={handleChange} />
    </form>
  );
}

8. Updating Arrays in State

Quick Reference Table

OperationMutating (BAD)Immutable (GOOD)
Add to endarr.push(item)[...arr, item]
Add to startarr.unshift(item)[item, ...arr]
Removearr.splice(i, 1)arr.filter(...)
Replacearr[i] = newItemarr.map(...)
Sortarr.sort()[...arr].sort()
Reversearr.reverse()[...arr].reverse()
Cleararr.length = 0[]

Adding Items

setItems(prev => [...prev, "Cherry"]);      // Add to end
setItems(prev => ["Cherry", ...prev]);      // Add to beginning

Removing Items

setItems(prev => prev.filter(item => item.id !== id));       // Remove by id
setItems(prev => prev.filter((_, i) => i !== index));        // Remove by index

Updating Items

setItems(prev => prev.map(item =>
  item.id === id ? { ...item, name: newName } : item
));

Toggling a Field

setItems(prev => prev.map(item =>
  item.id === id ? { ...item, complete: !item.complete } : item
));

Sorting

setItems(prev => [...prev].sort((a, b) => a.name.localeCompare(b.name)));

9. Updating Nested State

Every level of nesting requires a new copy.

Two Levels Deep

const [user, setUser] = useState({
  name: "Alice",
  address: { street: "123 Main St", city: "Portland" },
});

// Update city
setUser(prev => ({
  ...prev,
  address: { ...prev.address, city: "Seattle" },
}));

Three Levels Deep

const [company, setCompany] = useState({
  name: "Acme",
  ceo: {
    name: "Alice",
    contact: { email: "alice@acme.com", phone: "555-0100" },
  },
});

setCompany(prev => ({
  ...prev,
  ceo: {
    ...prev.ceo,
    contact: { ...prev.ceo.contact, email: "newalice@acme.com" },
  },
}));

For deeply nested state, consider:

  • Flattening the state into separate useState calls
  • Using structuredClone for deep copies
  • Using Immer for write-like syntax with immutable results

10. Multiple useState Calls

A component can have as many useState calls as it needs.

When to Use Multiple useState

function UserForm() {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [age, setAge] = useState(0);
  // Each piece of state is independent
}

When to Use a Single Object

function UserForm() {
  const [form, setForm] = useState({ name: "", email: "", age: 0 });

  function handleChange(field, value) {
    setForm(prev => ({ ...prev, [field]: value }));
  }
}

Decision Guide

FactorMultiple useStateSingle Object
Fields change independentlyBetterFine
Fields change togetherFineBetter
Many fields (5+)Gets verboseCleaner
Reset all at onceMust reset eachsetForm(initialState)

11. Lazy Initialization

When the initial state requires an expensive computation, use a function:

// BAD: expensiveCalculation runs on EVERY render
const [data, setData] = useState(expensiveCalculation());

// GOOD: expensiveCalculation runs only on FIRST render
const [data, setData] = useState(() => expensiveCalculation());

Don't use lazy initialization for cheap values:

// These are fine without lazy initialization
const [count, setCount] = useState(0);
const [name, setName] = useState("");
const [items, setItems] = useState([]);

12. Common Mistakes

Mistake 1: Mutating State Directly

// BAD: Direct mutation
user.name = "Bob";
setUser(user);

// GOOD: Create new object
setUser({ ...user, name: "Bob" });

Mistake 2: Reading State Immediately After Setting It

function handleClick() {
  setCount(count + 1);
  console.log(count); // Still the old value!

  // Fix: compute separately
  const newCount = count + 1;
  setCount(newCount);
  console.log(newCount);
}

Mistake 3: Setting State in the Render Body

function BadComponent() {
  const [count, setCount] = useState(0);
  setCount(count + 1); // INFINITE LOOP!
  return <p>{count}</p>;
}

Mistake 4: Stale Closures

When using setInterval or setTimeout, use functional updates to avoid capturing stale state values.

Mistake 5: Unnecessary State

// BAD: Derived value stored in state
const [items, setItems] = useState([...]);
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
  setFilteredItems(items.filter(i => i.active));
}, [items]);

// GOOD: Compute during render
const [items, setItems] = useState([...]);
const filteredItems = items.filter(i => i.active);

13. useState vs useReducer

CriterionuseStateuseReducer
Number of state values1-33+ related values
Update logic complexitySimpleComplex
Next state depends on multiple valuesRarelyOften
Testing state logic independentlyHarderEasy (reducer is pure function)

Rule of thumb: Start with useState. Switch to useReducer when you find yourself with multiple related state values and complex update logic.


14. Debugging State with React DevTools

Viewing State

  1. Install React Developer Tools (browser extension)
  2. Open DevTools, go to "Components" tab
  3. Click on any component
  4. See hooks/state values in the right panel

Editing State Live

Click on a state value in DevTools and change it. The component re-renders immediately.

Highlighting Re-renders

In DevTools settings, enable "Highlight updates when components render." Components that re-render flash with a colored border.

The Profiler Tab

  1. Go to "Profiler" tab
  2. Click record
  3. Interact with the app
  4. Click stop
  5. See which components rendered, why, and how long each took

15. Practical Examples

Example 1: Controlled Text Input

function TextInput() {
  const [text, setText] = useState("");
  return (
    <div>
      <input type="text" value={text} onChange={e => setText(e.target.value)} />
      <p>You typed: {text}</p>
      <p>Character count: {text.length}</p>
    </div>
  );
}

Example 2: Todo List

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState("");

  function addTodo(e) {
    e.preventDefault();
    if (!input.trim()) return;
    setTodos(prev => [...prev, { id: Date.now(), text: input.trim(), done: false }]);
    setInput("");
  }

  function toggleTodo(id) {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  }

  function removeTodo(id) {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }

  const remaining = todos.filter(t => !t.done).length;

  return (
    <div>
      <form onSubmit={addTodo}>
        <input value={input} onChange={e => setInput(e.target.value)} placeholder="Add todo..." />
        <button type="submit">Add</button>
      </form>
      <p>{remaining} items remaining</p>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} />
            <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>{todo.text}</span>
            <button onClick={() => removeTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Example 3: Accordion

function Accordion({ items }) {
  const [openIndex, setOpenIndex] = useState(null);

  function toggleItem(index) {
    setOpenIndex(prev => (prev === index ? null : index));
  }

  return (
    <div>
      {items.map((item, index) => (
        <div key={index}>
          <button onClick={() => toggleItem(index)}>
            {item.title} {openIndex === index ? "[-]" : "[+]"}
          </button>
          {openIndex === index && <div style={{ padding: "8px 16px" }}>{item.content}</div>}
        </div>
      ))}
    </div>
  );
}

Example 4: Color Picker

function ColorPicker() {
  const [color, setColor] = useState({ r: 100, g: 150, b: 200 });

  function handleChange(channel, value) {
    setColor(prev => ({ ...prev, [channel]: Number(value) }));
  }

  const hex = `#${color.r.toString(16).padStart(2, "0")}${color.g.toString(16).padStart(2, "0")}${color.b.toString(16).padStart(2, "0")}`;

  return (
    <div>
      <div style={{ width: 100, height: 100, backgroundColor: hex, border: "1px solid #000" }} />
      <p>Hex: {hex}</p>
      {["r", "g", "b"].map(channel => (
        <label key={channel}>
          {channel.toUpperCase()}: {color[channel]}
          <input
            type="range"
            min={0}
            max={255}
            value={color[channel]}
            onChange={e => handleChange(channel, e.target.value)}
          />
        </label>
      ))}
    </div>
  );
}

Example 5: Image Gallery

function ImageGallery({ images }) {
  const [currentIndex, setCurrentIndex] = useState(0);

  function goNext() {
    setCurrentIndex(prev => (prev + 1) % images.length);
  }

  function goPrev() {
    setCurrentIndex(prev => (prev - 1 + images.length) % images.length);
  }

  return (
    <div>
      <img src={images[currentIndex].src} alt={images[currentIndex].alt} />
      <div>
        <button onClick={goPrev}>Previous</button>
        <span>{currentIndex + 1} / {images.length}</span>
        <button onClick={goNext}>Next</button>
      </div>
    </div>
  );
}

Example 6: Shopping Cart Item

function CartItem({ product }) {
  const [quantity, setQuantity] = useState(1);
  const total = product.price * quantity;

  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price.toFixed(2)} each</p>
      <div>
        <button onClick={() => setQuantity(prev => Math.max(prev - 1, 1))}>-</button>
        <span>{quantity}</span>
        <button onClick={() => setQuantity(prev => Math.min(prev + 1, product.stock))}>+</button>
      </div>
      <p>Total: ${total.toFixed(2)}</p>
    </div>
  );
}

Example 7: Tab Component

function Tabs({ tabs }) {
  const [activeTab, setActiveTab] = useState(0);

  return (
    <div>
      <div role="tablist">
        {tabs.map((tab, index) => (
          <button
            key={index}
            role="tab"
            aria-selected={index === activeTab}
            onClick={() => setActiveTab(index)}
            style={{
              fontWeight: index === activeTab ? "bold" : "normal",
              borderBottom: index === activeTab ? "2px solid blue" : "none",
            }}
          >
            {tab.label}
          </button>
        ))}
      </div>
      <div role="tabpanel">{tabs[activeTab].content}</div>
    </div>
  );
}

Example 8: Password Visibility Toggle

function PasswordInput() {
  const [password, setPassword] = useState("");
  const [isVisible, setIsVisible] = useState(false);

  return (
    <div>
      <input
        type={isVisible ? "text" : "password"}
        value={password}
        onChange={e => setPassword(e.target.value)}
      />
      <button type="button" onClick={() => setIsVisible(prev => !prev)}>
        {isVisible ? "Hide" : "Show"}
      </button>
    </div>
  );
}

Example 9: Multi-Field Registration Form

function RegistrationForm() {
  const [form, setForm] = useState({
    username: "",
    email: "",
    password: "",
    confirmPassword: "",
  });

  function handleChange(e) {
    const { name, value } = e.target;
    setForm(prev => ({ ...prev, [name]: value }));
  }

  const passwordsMatch = form.password === form.confirmPassword;
  const isValid = form.username && form.email && form.password && passwordsMatch;

  return (
    <form>
      <input name="username" value={form.username} onChange={handleChange} placeholder="Username" />
      <input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
      <input name="password" type="password" value={form.password} onChange={handleChange} placeholder="Password" />
      <input name="confirmPassword" type="password" value={form.confirmPassword} onChange={handleChange} placeholder="Confirm" />
      {!passwordsMatch && form.confirmPassword && <p>Passwords do not match</p>}
      <button disabled={!isValid}>Register</button>
    </form>
  );
}

Example 10: Stopwatch

function Stopwatch() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef(null);

  function start() {
    if (isRunning) return;
    setIsRunning(true);
    intervalRef.current = setInterval(() => {
      setTime(prev => prev + 10);
    }, 10);
  }

  function stop() {
    setIsRunning(false);
    clearInterval(intervalRef.current);
  }

  function reset() {
    stop();
    setTime(0);
  }

  const minutes = Math.floor(time / 60000);
  const seconds = Math.floor((time % 60000) / 1000);
  const milliseconds = Math.floor((time % 1000) / 10);

  const display = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(milliseconds).padStart(2, "0")}`;

  return (
    <div>
      <p style={{ fontSize: "2rem", fontFamily: "monospace" }}>{display}</p>
      <button onClick={start} disabled={isRunning}>Start</button>
      <button onClick={stop} disabled={!isRunning}>Stop</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Key Takeaways

  1. useState returns [value, setter] -- value to read, setter to update.
  2. Initial value is used only on the first render. After that, React uses the stored value.
  3. Use lazy initialization (useState(() => ...)) for expensive initial computations.
  4. Use functional updates (setValue(prev => ...)) when new state depends on old state.
  5. Never mutate state directly. Always create new objects/arrays.
  6. State updates are asynchronous. You don't get the new value until the next render.
  7. Hooks must be called in the same order on every render.
  8. Multiple useState calls are fine -- group related data, separate unrelated data.
  9. Flatten deeply nested state to avoid painful spread chains.
  10. Start with useState, graduate to useReducer when update logic gets complex.

Explain-It Challenge

Pick one and explain it without using the word "state" or "render":

  • Why does calling setCount(count + 1) three times only add 1, not 3?
  • Why can't you just change user.name = "Bob" and have the screen update?
  • What's the difference between useState(heavyFunction()) and useState(heavyFunction) (no parentheses)?

Try explaining to someone who knows JavaScript but has never seen React.


Navigation: ← 2.3.a — What Is State · Next → 2.3.c — How React Re-renders