Skip to main content
PProfileMatch

React useState Hook: Managing State

Web DevelopmentLesson 3 of 64 min read

What you'll learn

  • Add state to a component with useState and update it correctly
  • Update objects and arrays immutably instead of mutating them
  • Explain why state updates are batched and when to use the functional form
  • Lift state up so sibling components can share it

On this page

  1. useState basics: a counter
  2. State is a snapshot: updates are batched
  3. Objects and arrays: never mutate, always copy
  4. Lifting state up
  5. Common mistakes
  6. How this shows up in interviews and real projects

A normal variable inside a component resets on every render and changing it does not update the screen. State is how a component remembers things between renders and tells React to redraw when they change. It is what turns a static page into an app.

useState basics: a counter

useState(initialValue) returns a pair: the current value and a function to update it. By convention you name them thing and setThing. Calling the setter schedules a re-render, and during that re-render useState returns the new value.

Counter.tsx
import { useState } from "react";

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

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

Output

Initially:            You clicked 0 times
After 3 clicks:       You clicked 3 times
After Reset:          You clicked 0 times

Hooks follow two rules. Call them only at the top level of a component (not inside loops, conditions or nested functions), and only from components or other hooks. React identifies each useState call by its position, so the order must be identical on every render.

State is a snapshot: updates are batched

Each render sees a fixed snapshot of state. Calling setCount(count + 1) does not change the count variable in the running function; it asks React for a new render with a new value. React also batches several updates from one event into a single render for performance.

This surprises almost everyone. The handler below looks like it adds 3, but it adds 1, because all three calls read the same snapshot value of count.

tsx
function addThreeWrong() {
  setCount(count + 1); // count is 0 in this snapshot -> asks for 1
  setCount(count + 1); // asks for 1 again
  setCount(count + 1); // asks for 1 again
  console.log(count); // still 0, not 3
}

function addThreeRight() {
  setCount((c) => c + 1); // c is the latest pending value
  setCount((c) => c + 1);
  setCount((c) => c + 1); // result: 3
}

Output

After clicking addThreeWrong from 0:  count becomes 1
After clicking addThreeRight from 0:  count becomes 3

Objects and arrays: never mutate, always copy

React decides whether state changed by comparing the old and new value with Object.is. If you mutate an array or object in place and pass the same reference to the setter, React sees no difference and may skip the update. Treat state as immutable: create a new object or array and pass that.

TodoApp.tsx
import { useState } from "react";

type Todo = { id: number; text: string; done: boolean };

export default function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: "Learn useState", done: false },
  ]);
  const [text, setText] = useState("");

  function addTodo() {
    if (text.trim() === "") return;
    // new array: old items plus the new one
    setTodos([...todos, { id: Date.now(), text: text.trim(), done: false }]);
    setText("");
  }

  function toggleTodo(id: number) {
    // map returns a new array; changed item is a new object
    setTodos(todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
  }

  function removeTodo(id: number) {
    setTodos(todos.filter((t) => t.id !== id));
  }

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <span style={{ textDecoration: t.done ? "line-through" : "none" }}>
              {t.text}
            </span>
            <button onClick={() => toggleTodo(t.id)}>Toggle</button>
            <button onClick={() => removeTodo(t.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Output

[ input box ] [Add]
- Learn useState   [Toggle] [Delete]
Typing "Buy milk" and pressing Add appends:
- Buy milk         [Toggle] [Delete]

The same idea applies to objects. To change one field of a profile object, spread the old one and override the field: setProfile({ ...profile, city: "Pune" }). For nested objects, copy each level you change. If that gets painful, keep state flatter or split it into several useState calls.

Lifting state up

When two components need the same data, move the state to their closest common parent and pass it down as props, with a callback for changes. In a shopping cart, the product list adds items and the header badge shows the count, so the cart state lives in the parent that contains both.

Shop.tsx
import { useState } from "react";

function ProductList({ onAdd }: { onAdd: (name: string) => void }) {
  const products = ["Keyboard", "Mouse", "Monitor"];
  return (
    <ul>
      {products.map((p) => (
        <li key={p}>
          {p} <button onClick={() => onAdd(p)}>Add to cart</button>
        </li>
      ))}
    </ul>
  );
}

function CartBadge({ count }: { count: number }) {
  return <span>Cart ({count})</span>;
}

export default function Shop() {
  const [cart, setCart] = useState<string[]>([]);

  return (
    <div>
      <CartBadge count={cart.length} />
      <ProductList onAdd={(name) => setCart([...cart, name])} />
    </div>
  );
}

Output

Cart (0)
Keyboard [Add to cart]
Mouse    [Add to cart]
Monitor  [Add to cart]
After clicking Mouse twice the header reads: Cart (2)

Keep state as close as possible to where it is used, and lift it only when siblings need to share it. Lifting too early makes parents noisy; lifting too late leads to duplicated, out-of-sync copies of the same data.

Common mistakes

  • Mutating state directly. todos.push(item); setTodos(todos); keeps the same array reference. Symptom: the list does not update, or updates only after some unrelated re-render. Always create a new array.
  • Reading state right after setting it. setCount(5); console.log(count); logs the old value. Compute the next value in a variable if you need it immediately.
  • Stale values in timers. A setInterval created once that uses setCount(count + 1) increments only once, because it keeps the first snapshot. Use setCount((c) => c + 1).
  • Storing derived data in state. Keeping both todos and doneCount in state invites them to disagree. Calculate todos.filter(t => t.done).length during render instead.
  • Calling hooks conditionally. if (loggedIn) { useState(0) } breaks hook ordering. Symptom: the error "Rendered more hooks than during the previous render".

How this shows up in interviews and real projects

Interviewers love to ask why setState is not synchronous, how you would update an item in an array, and why mutating state is a bug. Being able to explain the snapshot model and write the map/spread update from memory covers most of these. In real projects the same patterns appear in carts, forms, filters and toggles. Next, React Events and Forms: Controlled Inputs shows how state and user input work together.

Try it yourself

Build a profile editor: an object in state with name and city, two inputs that update it, and a line below showing "Asha lives in Pune" that updates live. Then add a "Skills" list where you can add and remove skills from an array in state.

Show a hint

Use setProfile({ ...profile, name: e.target.value }) for the object and [...skills, newSkill] / skills.filter(...) for the array. Do not keep a separate state variable for the summary line; build it during render.

Key takeaways

  • useState gives a component memory; calling the setter schedules a re-render with the new value.
  • Each render sees a snapshot of state, and multiple updates in one event are batched together.
  • Use the functional form setX((prev) => ...) when the next value depends on the previous one.
  • Never mutate state: copy arrays and objects with spread, map and filter.
  • Lift state to the nearest common parent when siblings need to share it.

Test yourself

A trick from this lesson's ride: A plain variable is a whiteboard wiped each render; state is a notebook React keeps. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

Why is my React state not updating immediately?

State updates are scheduled and batched, and each render uses a fixed snapshot of state. The variable in your running function keeps its old value until React re-renders. If you need the next value right away, store it in a local variable, or use the functional update form when it depends on the previous value.

How do I update an array in React state?

Create a new array instead of changing the old one. Use spread to add ([...items, newItem]), filter to remove, and map to change one element. Pass the new array to the setter so React sees a different reference.

What does lifting state up mean in React?

It means moving state from a child to the closest common parent of the components that need it, then passing the value down as props and changes up through callback props. This keeps one source of truth instead of duplicated copies that can drift apart.