React State

State is the data a component owns and manages over time — values that change in response to user interaction, network responses, or the passage of time, and that cause the user interface to update automatically when they change. Unlike an ordinary JavaScript variable, updating state through React’s useState hook tells React that a component needs to re-render, keeping what’s on screen in sync with what’s in memory. Nearly every interactive component — a counter, a form, a toggle, a shopping cart — relies on state to remember something between renders. This lesson covers how state works internally, how to declare and update it correctly, the mistakes that trip up nearly every React developer, and how to use it well.

Overview / How State Works

A React function component is, at its core, just a function that returns JSX describing what the UI should look like right now. Every time that function runs, it produces a new description of the UI based on its current props and state. React compares that description to the previous one (a process called reconciliation) and updates only the parts of the real DOM that actually changed (the commit phase). This is the render → reconcile → commit cycle that powers every React update.

Plain local variables don’t survive between renders — a function’s local variables are recreated every time it runs. State solves this problem. When you call useState, React stores the value outside the component function, in memory tied to that specific component instance, and hands you back the current value plus a setter function. Calling the setter does two things: it updates the stored value, and it schedules a re-render of the component. On the next render, useState returns the updated value instead of the initial one.

Because state updates trigger a full re-run of the component function, updating state is the only supported way to make the UI reflect new data. Directly mutating a variable, or mutating an object or array that state points to, does not schedule a re-render and does not notify React that anything changed — which is why immutability matters so much in React (covered in Common Mistakes below).

Why hooks must run in the same order every render

React does not track state by variable name — it tracks it by call order. Internally, each component instance has an ordered list of hook “slots.” The first useState call in a render always reads/writes slot 0, the second always reads/writes slot 1, and so on. If a hook is called conditionally (inside an if, a loop, or after an early return), the slot order can shift between renders, and React will hand back the wrong stored value for the wrong hook — corrupting state silently or throwing an error. This is why the Rules of Hooks require hooks to be called unconditionally, at the top level of a component, in the same order on every render.

Batching

In React 18+, multiple state updates that happen inside the same event handler (or any React-triggered callback) are batched into a single re-render for performance, rather than re-rendering once per setState call. This means state you just “set” is not immediately available in the same function — the component doesn’t re-render mid-function, it re-renders after the handler finishes. Each render also captures its own “snapshot” of state and props via closures — a detail that explains several of the common mistakes below.

Syntax

import { useState } from "react";

const [state, setState] = useState(initialValue);
Part Meaning
useState(initialValue) Declares a state variable. initialValue is used only on the very first render.
state The current value for this render — read-only; never assign to it directly.
setState The updater function. Call it with a new value, or a function, to schedule a re-render.
setState(newValue) Replaces the state with newValue directly.
setState(prev => next) Functional update — receives the latest state and returns the next state. Safer when the new value depends on the old one.
useState(() => expensiveValue()) Lazy initialization — the function only runs once, on mount, instead of on every render.

Examples

Example 1: A simple counter

import { useState } from "react";

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

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

export default Counter;

Renders a paragraph reading “Count: 0” and a button labeled “Increment.” Each click calls setCount with the current count plus one, which schedules a re-render; on the next render, count reflects the new value and the text updates. Clicking three times in a row shows “Count: 3.”

Example 2: A controlled form with object state

import { useState } from "react";

function ProfileForm() {
  const [profile, setProfile] = useState({ name: "", email: "" });

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

  function handleSubmit(e) {
    e.preventDefault();
    console.log("Submitted:", profile);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={profile.name}
        onChange={handleChange}
        placeholder="Name"
      />
      <input
        name="email"
        value={profile.email}
        onChange={handleChange}
        placeholder="Email"
      />
      <button type="submit">Save</button>
    </form>
  );
}

export default ProfileForm;

Renders a form with two controlled text inputs (Name, Email) and a Save button. Typing in either field fires handleChange, which spreads the previous profile object and overwrites only the field that changed, using the input’s name attribute as a computed key — this keeps the other field’s value intact. Submitting logs the current object.

Output:
Submitted: {name: "Ada Lovelace", email: "ada@example.com"}

This pattern — spreading the previous object and overwriting one key — is the standard way to update part of an object in state without mutating it.

Example 3: An array of items (add and remove)

import { useState } from "react";

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [text, setText] = useState("");

  function addTodo() {
    if (text.trim() === "") return;
    setTodos([...todos, { id: Date.now(), text }]);
    setText("");
  }

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

  return (
    <div>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="New todo"
      />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.text}
            <button onClick={() => removeTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoList;

Renders a text input, an Add button, and a list of todos each with its own Delete button. Adding a todo builds a brand-new array with the spread operator instead of mutating the existing one; removing a todo builds a new filtered array. Both operations produce a fresh array reference, which is exactly what React needs to detect the change and re-render the list. The key={todo.id} on each <li> lets React match list items across renders efficiently, even after items are added or removed.

How It Works Step by Step (Under the Hood)

On mount: React calls the component function for the first time. Each useState call registers a new slot with its initial value and returns that value. React builds a virtual DOM tree from the returned JSX and commits it to the real DOM.

On a state update: An event handler calls a setter function. React stores the new value in that hook’s slot and schedules a re-render (batched with any other updates in the same handler). On the next render, the component function runs again from the top; each useState call now returns the updated value from its slot instead of the initial value. React builds a new virtual DOM tree, diffs it against the previous one, and patches only the real DOM nodes that actually changed — it does not tear down and rebuild the whole subtree.

On unmount: When a component is removed from the tree (e.g., a conditional stops rendering it, or its parent unmounts), React discards all of its hook state permanently. If the component is mounted again later, every useState call starts over from its initial value — state is not preserved across unmounts.

Common Mistakes

Mistake 1: Mutating state directly

// Wrong — mutates the existing array in place
function addTodo() {
  todos.push({ id: Date.now(), text: "New" });
  setTodos(todos);
}

Why it’s wrong: push mutates the same array reference that todos already points to. When you then call setTodos(todos), you’re handing React the exact same reference it already had. React’s re-render check and downstream memoization both rely on detecting a new reference — passing back the identical object can cause the UI to skip updating, or produce inconsistent behavior with tools like React.memo.

// Correct — creates a new array
function addTodo() {
  setTodos([...todos, { id: Date.now(), text: "New" }]);
}

Mistake 2: Reading a stale value across multiple updates

// Wrong — all three calls read the same stale "count" from this render
function handleTripleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}

Why it’s wrong: count is a snapshot captured when this render’s function body ran; it does not change mid-function even though setCount is called three times. All three calls compute count + 1 from the same stale value, so the count only ends up one higher, not three.

// Correct — functional updates always use the latest value
function handleTripleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
}

Mistake 3: Calling a hook conditionally

// Wrong — hook is skipped on renders where "user" is falsy
function UserBadge({ user }) {
  if (!user) return null;
  const [visible, setVisible] = useState(true);
  return <span>{visible ? user.name : "Hidden"}</span>;
}

Why it’s wrong: This violates the Rules of Hooks. Whether useState runs at all now depends on the user prop, so the hook call order can shift between renders — React relies on a stable order to match each hook call to its stored state, and a shifting order corrupts that matching.

// Correct — hook runs unconditionally, before any early return
function UserBadge({ user }) {
  const [visible, setVisible] = useState(true);
  if (!user) return null;
  return <span>{visible ? user.name : "Hidden"}</span>;
}

Best Practices

  • Use the functional update form (setState(prev => ...)) whenever the new state depends on the previous state, especially inside event handlers that might run multiple times or be batched.
  • Never mutate state directly — always build a new array ([...arr, item], arr.filter(...), arr.map(...)) or a new object ({ ...obj, key: value }).
  • Keep state minimal. If a value can be calculated from existing props or state during render, don’t store it in state — derive it instead of duplicating it.
  • Split unrelated pieces of data into separate useState calls rather than one large object, so unrelated updates don’t force you to spread the whole object every time.
  • Lift state up to the closest common parent when two or more sibling components need to share and stay in sync with the same data.
  • Use lazy initialization (useState(() => computeInitial())) when the initial value is expensive to compute, so it only runs once on mount instead of on every render.
  • Always give list items a stable, unique key (an id, not an array index when the list can reorder) so React can match items correctly across re-renders.

Practice Exercises

  • Exercise 1: Build a ToggleButton component that starts showing the text “OFF” and switches to “ON” (and back) each time it’s clicked, using a single boolean piece of state.
  • Exercise 2: Build a ShoppingCart component with an array of { id, name, price } items in state. Render each item with a “Remove” button that deletes only that item from the cart without mutating the array.
  • Exercise 3: Take the broken counter below and fix it so that clicking the button once always increases the count by 3, not 1: function handleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1); }

Summary

  • State is data a component owns that persists between renders and, when updated, tells React to re-render the component.
  • useState(initialValue) returns a [state, setState] pair; the initial value is used only on the first render.
  • React tracks hooks by call order, not by name — hooks must always run unconditionally at the top level of a component, in the same order every render.
  • State updates are batched inside event handlers in React 18+, and each render captures its own snapshot of state via closures.
  • State must always be updated immutably — replace arrays and objects with new copies rather than mutating them in place.
  • Use the functional updater form (prev => next) when a new value depends on the previous one, to avoid stale-value bugs.
  • Unmounting a component permanently discards its state; remounting starts fresh from the initial value.