useState Hook

The useState hook is the most fundamental tool for adding state to a function component in React. It lets a component remember a value between renders and tells React to re-render the component whenever that value changes. Without useState (or another state hook), a function component is just a plain function that returns the same output every time it runs — useState is what turns it into an interactive piece of UI that can react to clicks, input, and data changes.

Overview / How It Works

Calling useState(initialValue) inside a function component asks React to create and track a single piece of state for that component instance. It returns an array with exactly two items: the current value, and a function you call to update it. By convention you destructure this array: const [count, setCount] = useState(0);.

Here is the part that matters for understanding React deeply: a function component is just a function, and every time React needs to update the screen, it calls that function again from the top. Local let or const variables inside the function body would normally reset to their initial value on every call — that is how plain JavaScript functions work. React solves this by storing state outside the function, attached to an internal data structure called a fiber (one fiber node per component instance in the tree). When your component function runs, useState does not create a fresh value — it looks up the value React already has stored for this component and returns it.

This is also why React enforces the Rules of Hooks: hooks must be called in the exact same order on every render, and never inside conditionals, loops, or nested functions. React does not track state by variable name — it tracks it by call order (call 1 is count, call 2 is name, and so on). If a hook call is skipped on some render because it sits inside an if, every hook call after it shifts by one slot and reads the wrong stored value.

When you call the setter function returned by useState — for example setCount(5) — you are not mutating anything immediately. You are telling React: the next time you render this component, use this new value instead. React then schedules a re-render: it calls your component function again, this time useState returns the new value, and React builds a new description of the UI (JSX compiles down to calls that build this description). React compares the new description against the previous one — a process called reconciliation that works against an in-memory representation often called the Virtual DOM — and computes the minimal set of real DOM changes needed. Only those changes are applied to the actual browser DOM during the commit phase. This is why updating state is cheap even though it looks like the whole component re-runs: the expensive part, touching the real DOM, is kept to a minimum.

React 18 also batches multiple state updates that happen within the same event handler — and even inside promises, timeouts, and other async callbacks — into a single re-render, rather than re-rendering once per setter call. This matters when you call a setter more than once in a row; see Common Mistakes below.

Finally, React compares the new value you pass to the setter against the current value using Object.is. If they are the same primitive value, or the same object/array reference, React bails out and skips re-rendering that component entirely. This is exactly why state must always be treated as immutable.

Syntax

import { useState } from "react";

function Component() {
  const [state, setState] = useState(initialValue);
  // ...
}
Part Description
useState A hook imported from "react". Must be called at the top level of a function component or custom hook.
initialValue The value used only on the very first render. Can be a number, string, boolean, array, object, or null.
state The current value, valid for this render. Treat it as read-only — never assign to it directly.
setState The updater function returned alongside state. Calling it schedules a re-render with the new value.
Lazy initializer Passing a function instead of a value, e.g. useState(() => expensiveComputation()), runs that function only once on mount instead of on every render.

Examples

Example 1: A Basic Counter

import { useState } from "react";

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

export default Counter;

This renders a paragraph showing the current count and two buttons. Clicking Increment calls setCount(count + 1), which schedules a re-render with the new value; React re-runs Counter, and the paragraph shows the updated count. Clicking Reset sets it directly back to 0. Notice that count is treated as read-only — the component never writes to count directly, only calls setCount.

Output: Initially renders “Count: 0”. After clicking Increment three times, renders “Count: 3”. Clicking Reset returns it to “Count: 0”.

Example 2: Object State With a Form

import { useState } from "react";

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

  function handleNameChange(e) {
    setProfile({ ...profile, name: e.target.value });
  }

  function handleEmailChange(e) {
    setProfile({ ...profile, email: e.target.value });
  }

  return (
    <form>
      <label htmlFor="name">Name</label>
      <input
        id="name"
        value={profile.name}
        onChange={handleNameChange}
      />
      <label htmlFor="email">Email</label>
      <input
        id="email"
        value={profile.email}
        onChange={handleEmailChange}
      />
      <p>Preview: {profile.name} ({profile.email})</p>
    </form>
  );
}

export default ProfileForm;

This is a controlled form: each input’s value comes from state, and every keystroke fires onChange, which updates state. Because profile is a single object holding both fields, updating just the name must not lose the email — that is why handleNameChange spreads the existing profile first ({ ...profile, name: e.target.value }) and only overwrites the name key. Writing profile.name = e.target.value directly would mutate state in place and React would never notice the change.

Output: Typing “Ada” in the name field and “ada@example.com” in the email field renders “Preview: Ada (ada@example.com)” and updates live on every keystroke.

Example 3: Array State — a Todo List

import { useState } from "react";

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

  function handleAdd(e) {
    e.preventDefault();
    if (text.trim() === "") return;
    setTodos((prevTodos) => [...prevTodos, { id: Date.now(), text }]);
    setText("");
  }

  function handleRemove(id) {
    setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
  }

  return (
    <div>
      <form onSubmit={handleAdd}>
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="Add a task"
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.text}
            <button onClick={() => handleRemove(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoList;

This example manages two independent pieces of state: the array of todos and the current text in the input. Both handleAdd and handleRemove use the functional update form, setTodos((prevTodos) => ...), instead of referencing todos directly. This guarantees the update always builds on the most recent state, which matters if multiple updates happen close together. Every rendered <li> also gets a stable key (todo.id) so React can correctly match list items across renders instead of re-creating them.

Output: Typing “Buy milk” and clicking Add renders a list item reading “Buy milk” with a Delete button, and clears the input. Clicking Delete on that item removes it from the rendered list.

How It Works Step by Step

On mount: React calls the component function for the first time. Each useState call creates a new state slot on the component’s fiber, seeded with initialValue (or the return value of the lazy initializer function, called exactly once). The returned JSX becomes a tree of React elements, which React uses to build the real DOM for the first time.

On a state update: Calling a setter, e.g. setCount(count + 1), does not change anything synchronously. React marks the component as needing an update and schedules a re-render, batched together with any other updates triggered in the same event handler. When React gets to it, it calls the component function again; this time each useState call returns the latest stored value instead of initialValue. React takes the newly returned JSX, compares it against the tree from the previous render (reconciliation), and computes the minimal DOM mutations. Those mutations are applied in the commit phase, and the browser paints the update.

On unmount: When a component is removed from the tree — for example, a parent stops rendering it — React discards its fiber, and every piece of state created with useState in that component is thrown away. If the component mounts again later, its state starts over from initialValue; state does not persist across unmounts.

Common Mistakes

Mistake 1: Mutating State Directly

function TodoList() {
  const [todos, setTodos] = useState(["Buy milk"]);

  function addTodo(newTodo) {
    todos.push(newTodo);    // mutates the existing array in place
    setTodos(todos);        // same reference -> React skips the re-render
  }
  // ...
}

This looks reasonable, but todos.push(newTodo) mutates the array that state already points to, and then setTodos(todos) passes back that exact same reference. React compares the new value to the old one with Object.is; since it is literally the same array object, React sees no reason to re-render, and the UI never updates even though the underlying array changed. Fix it by creating a new array:

function addTodo(newTodo) {
  setTodos((prevTodos) => [...prevTodos, newTodo]);
}

The same rule applies to objects: always spread into a new object ({ ...user, name }) rather than assigning to a property of the existing one.

Mistake 2: Reading Stale State for Multiple Updates

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
}
// After one click, count only increases by 1, not 3.

Because React batches these three calls into a single re-render, all three lines run before count is ever updated — each one reads the same stale count captured in this render’s closure and computes the same “current value + 1”. The fix is to use the functional updater form, which always receives the truly latest pending state instead of the value captured in the closure:

function handleClick() {
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
  setCount((prev) => prev + 1);
}
// After one click, count correctly increases by 3.

The same stale-closure problem shows up inside setTimeout or useEffect callbacks — whenever a state update depends on the previous value, prefer setX((prev) => ...) over setX(x + 1).

Best Practices

  • Never mutate state or props directly — always call the setter with a new array or object (spread syntax, .map, or .filter) so React can detect the change.
  • Use the functional updater form, setState((prev) => ...), whenever the new value depends on the previous one, especially inside event handlers, effects, or async callbacks.
  • Split unrelated values into separate useState calls (e.g. name and email as two states) rather than one giant state object, unless the values genuinely change together.
  • Use the lazy initializer form, useState(() => computeInitial()), when computing the initial value is expensive, so it only runs once instead of on every render.
  • Do not call useState, or any hook, inside conditionals, loops, or after an early return — always call hooks unconditionally at the top of the component.
  • Keep state as small and minimal as possible — derive values you can compute from existing state during render instead of storing them in another useState.

Practice Exercises

  1. Build a LikeButton component with a liked boolean state, default false. Clicking it should toggle the button text between “Like” and “Liked”.
  2. Build a ShoppingCart component that stores an array of item names in state. Add an input and an “Add Item” button that appends a new item, and render each item in a list with a “Remove” button next to it. Make sure you never mutate the array directly.
  3. Build a component with a seconds number state and a button labeled “Add 3” that calls the setter three times in a single click handler so the count increases by 3 in one click — you will need the functional updater form from the Common Mistakes section to make it work correctly.

Summary

  • useState adds a single piece of state to a function component and returns [value, setValue].
  • State is stored by React outside the component function, on its fiber, which is why it survives across renders even though the function itself re-runs from scratch.
  • Calling the setter schedules a re-render; React then reconciles the new JSX against the previous render and commits only the minimal DOM changes.
  • State must always be updated immutably — replace arrays and objects with new ones rather than mutating them in place.
  • Use the functional updater form, setState((prev) => ...), whenever a new state value depends on the previous one.
  • Hooks must be called in the same order on every render — never inside conditionals, loops, or nested functions.