Lesson 3

State & useState

Home › Courses › React › Lesson 3
⚛️ Lesson 3 · 20 minutes

State & useState.
Where React clicks.

Props make components configurable. State makes them interactive. When state changes, React re-renders — the UI updates to reflect the new value. This is the moment React stops feeling like fancy HTML and starts feeling like an application. Every interactive feature you'll ever build — counters, forms, toggles, search, filters, carts — is built on this one hook.

~20 minutes Beginner React 19
React path ← Lesson 2 First state Why useState Async & batching Updater function Objects & arrays Lifting state Challenge

const [count, setCount] = useState(0) gives a function component local state. The first item is the current value — read-only. The second is the setter — the only way to update it. Calling the setter triggers a re-render. Never mutate state directly; always use the setter. When state depends on the previous value, use the updater form: setCount(prev => prev + 1). Hooks must be called unconditionally at the top of the component.

Before this lesson: complete Lesson 2. You need to understand props before learning state.

🎯 Your first piece of state

One line adds state. The setter updates it. React re-renders.

Before state, components were static — they rendered the same thing every time. With state, a component can hold data that changes: a counter, a form value, a list of items. The mechanism is small: call useState, get back the current value and a setter, call the setter to change it, React re-renders.

src/Counter.jsx
import { useState } from 'react';

function Counter() {
// Declare a state variable 'count', initialized to 0
// setCount is the ONLY way to update it
const [count, setCount] = useState(0);

return (
'2rem', textAlign: 'center' }}>

'3rem', margin: '0 0 1rem' }}> {count}

); } export default Counter;

useState(initialValue)

Returns [value, setter]. The initial value is only used on the first render — subsequent renders ignore it. Initial can be a primitive, object, array, or function.

The destructure

const [count, setCount] = useState(0) — array destructuring. Name them whatever you want, but the convention is [value, setValue].

Reading state

Use count anywhere in the JSX or logic. It's the current value from the last render. Each render gets a fresh snapshot of state.

Updating state

Call setCount(newValue). React schedules a re-render. On the next render, count will be the new value. Calling the setter doesn't return anything.

The mental model: state is a snapshot. Each render, React gives your component a fresh count value — a snapshot of what state is right now. When you call setCount(5), React doesn't change count in the current render. It schedules the next render with the new value. That's why logging count right after setCount shows the OLD value — you're still looking at this render's snapshot. This concept unlocks everything else about React state.

🤔 Why useState — not a regular variable

A regular let count = 0 fails in two ways. useState fixes both.

Two failures of regular variables
// ❌ BROKEN — regular variable doesn't work
function BrokenCounter() {
let count = 0; // resets to 0 every render

function handleClick() {
count = count + 1; // changes the local variable
console.log(count); // logs 1, 2, 3...
}

return ;
}

// Two problems:
// 1. React doesn't re-render when 'count' changes.
// The UI stays at 0 forever.
// 2. Even if it did re-render, 'let count = 0' resets
// to 0 on every render. The value is lost.
// useState fixes both: it persists the value across
// renders AND triggers a re-render when it changes.

// ─────────────────────────────────────────

// ✅ CORRECT — useState works
function WorkingCounter() {
const [count, setCount] = useState(0);

function handleClick() {
setCount(count + 1); // schedules a re-render
}

return ;
}

// 1. React re-renders when setCount is called.
// 2. The value persists across renders (React manages it).
// The UI updates to reflect the new count.

Persistence

Regular variables reset on every render. useState values persist. React stores them outside the function, associated with this component instance.

Triggers re-render

Regular variables don't cause re-renders. Calling the setter does. React re-runs the component function, gets new JSX, and patches the DOM.

Multiple state variables

Call useState as many times as you need. Each call is independent. const [name, setName] = useState(''), const [age, setAge] = useState(0).

One state per concern

Don't put everything in one state object. Split by what changes together. name and age are separate states. firstName + lastName might be one object if they always change together.

⏱️ Async state & batching

setState doesn't update immediately. Understanding why prevents a whole class of bugs.

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

function handleClick() {
setCount(count + 1);
console.log(count); // LOGS THE OLD VALUE!
// Why? Because setCount schedules a re-render.
// The current render still has the old count value.
// The new value is available NEXT render.
}

// Three clicks in a row — how much does count increase?
function handleTripleClick() {
setCount(count + 1); // uses this render's count
setCount(count + 1); // uses this render's count again
setCount(count + 1); // uses this render's count again
// Result: count increases by 1, not 3!
// All three calls use the SAME stale count value.
// The last call wins (batched).
}

return (

Count: {count}

); }

setState is async

Calling setCount(5) doesn't change count in the current render. It schedules the next render with the new value. count stays the same until that render happens.

State is a snapshot

Each render captures the current value of every state variable. The count variable in this render is frozen — it's the value from when the render started, even after setState is called.

Batching

React batches multiple setState calls in the same event handler into one re-render. Three setCount calls = one re-render. Efficient, but the staleness is the tradeoff.

The consequence

If new state depends on old state, don't use setCount(count + 1). It uses the stale value. Use the updater form (next section) to always get the latest value.

The stale-state trap. setCount(count + 1) inside a click handler works fine once. But call it three times in a row and count only increases by 1 — because all three calls use the same count value from the current render. This isn't a React bug, it's a consequence of state being a snapshot. Fix: use the updater form setCount(prev => prev + 1), which always receives the latest pending state. Do this any time the new state depends on the old state.

🔁 The updater form — when state depends on state

Pass a function to the setter. It receives the previous value. Always current.

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

// ❌ WRONG — uses stale count on each call
function badTriple() {
setCount(count + 1); // count = 0, schedules 1
setCount(count + 1); // count is STILL 0, schedules 1
setCount(count + 1); // count is STILL 0, schedules 1
// Result: count increases by 1
}

// ✅ CORRECT — each call receives the pending value
function goodTriple() {
setCount(prev => prev + 1); // prev = 0, schedules 1
setCount(prev => prev + 1); // prev = 1, schedules 2
setCount(prev => prev + 1); // prev = 2, schedules 3
// Result: count increases by 3
}

return (

Count: {count}

); } // Rule of thumb: // If new state = f(old state), use the updater form. // If new state = a specific new value, direct is fine. // // setCount(5) ← fine (setting a specific value) // setCount(count + 1) ← WRONG if called multiple times // setCount(prev => prev+1) ← always correct

Direct update

setCount(5) — sets count to 5. Works when you have the exact new value. Doesn't depend on previous state.

Updater update

setCount(prev => prev + 1) — passes a function. React calls it with the pending state. Always returns based on the latest value.

When it matters

Multiple setState calls in one handler. SetState inside setTimeout / useEffect where the value might be stale. Anything where "the current count" isn't what you have in scope.

Always safe

The updater form always works. It's more verbose but never wrong. When in doubt, use it. Many teams use it exclusively for numeric state changes.

Use the updater form by default for numeric state. setCount(prev => prev + 1) reads slightly more verbosely than setCount(count + 1), but it's never wrong. The direct form only works when you're certain the value in scope is current — which is usually true in a single click handler, but breaks in loops, setTimeout callbacks, and other async contexts. The updater form doesn't have that problem. Make it a habit.

📦 Objects and arrays in state

Never mutate. Always replace. Spread to create a new value.

Objects and arrays
// ❌ BROKEN — mutating state
const [user, setUser] = useState({ name: 'Alice', age: 28 });

function badRename() {
user.name = 'Bob'; // mutates the object directly
// React doesn't detect the change — no re-render.
// The UI stays at "Alice" even though user.name is "Bob".
}

// ─────────────────────────────────────────

// ✅ CORRECT — spread to create new object
const [user, setUser] = useState({ name: 'Alice', age: 28 });

function goodRename() {
setUser({ ...user, name: 'Bob' });
// New object with name updated, age preserved.
// React sees a new reference → re-renders → UI updates.
}

// ─────────────────────────────────────────

// Nested objects — spread at each level
const [user2, setUser2] = useState({
name: 'Alice',
address: { city: 'NYC', zip: '10001' },
});

function moveCity() {
setUser2({
...user2,
address: { ...user2.address, city: 'LA' },
});
}

// ─────────────────────────────────────────

// Arrays — same pattern
const [items, setItems] = useState(['apple', 'banana']);

// Add
setItems([...items, 'cherry']);

// Remove
setItems(items.filter(item => item !== 'banana'));

// Replace one
setItems(items.map(item => item === 'apple' ? 'apricot' : item));

// Remove by id (objects)
setUsers(users.filter(u => u.id !== idToDelete));

Never mutate

user.name = 'Bob' and items.push(x) modify the existing object. React compares state by reference — same reference means no re-render.

Always create new

{ ...user, name: 'Bob' } creates a new object. [...items, x] creates a new array. New reference = React detects the change = re-render.

Spread is shallow

{ ...user } copies top-level properties but nested objects are still referenced. For nested updates, spread at each level.

Array methods return new

filter, map, slice, concat — all return new arrays. push, pop, splice, sort mutate in place. Use the non-mutating versions.

The most common React bug: mutating state directly. user.name = 'Bob' looks like it should work — the value changes. But React never re-renders, because the object reference hasn't changed. The UI stays stuck at the old value while the state silently diverges. Fix: always use the setter with a new object or array. If your UI isn't updating, check whether you mutated state by accident — it's the cause 80% of the time.

⬆️ Lifting state up

Two components need the same state? Move it to their closest common parent.

Lifted state
// Both Display and Controls need the count.
// So App owns the state and passes it down.
function App() {
const [count, setCount] = useState(0);

return (
setCount(prev => prev + 1)} onReset={() => setCount(0)} />
); } // Display receives count as a prop — reads only function Display({ count }) { return

{count}

; } // Controls receives callbacks — doesn't touch state directly function Controls({ onIncrement, onReset }) { return (
); } // Why this pattern: // - Single source of truth — count lives in one place (App) // - Display and Controls stay simple — no internal state // - The setter and callbacks flow down as props // - When count changes, both children re-render with the new value

Closest common parent

Move state to the nearest ancestor of every component that needs it. Not the top of the tree — the closest common parent. Keep it as low as possible.

Single source of truth

If two components have their own copy of the same state, they drift apart. One central state = guaranteed consistency. This is React's core promise.

Data down, callbacks up

Parent passes state as props (data down). Parent passes setters or callbacks as props (events up). Children stay dumb and reusable — they don't own state.

When NOT to lift

If only one component needs the state, keep it local. Lifting is for sharing, not for tidiness. Over-lifting makes components harder to reuse.

The "single source of truth" is what makes React predictable. When state lives in one place, there's exactly one correct value at any moment. No sync bugs. No "which copy is right?" No drift between components. This is why React discourages keeping the same state in multiple components — even if it feels convenient in the moment, it always leads to bugs. Lifting state up is the standard fix, and it's what you should reach for whenever two components need to stay in sync.

🏆 Challenge

Three tasks. Each exercises a different state pattern.

Task 1 — Counter with limits

Build a counter component that:

1. Has +1 and -1 buttons
2. Won't go below 0 (disable the -1 button when count is 0)
3. Won't go above 10 (disable the +1 button when count is 10)
4. Has a Reset button that sets count back to 0
5. Shows the count in a large heading

Use the updater form for the +1 and -1 handlers. Test by clicking rapidly — count should never go out of bounds.

Task 2 — Todo list with state

Build a todo list component with:

1. State for the list of todos (array of objects: { id, text, done })
2. State for the input field value
3. An "Add" button that appends a new todo to the list
4. Each todo has a "Delete" button
5. Each todo has a checkbox to toggle done status

All state updates must use the immutable pattern: [...todos, newTodo], todos.filter(...), todos.map(...). Never mutate the array or the todo objects directly.

Task 3 — Lift state up

Build a temperature converter:

1. Two input fields: Celsius and Fahrenheit
2. Typing in Celsius updates Fahrenheit, and vice versa
3. Both inputs are their own components (CelsiusInput, FahrenheitInput)
4. State lives in a parent (App) as a single temperature value
5. Both children receive the value and a setter

Test: type 100 in Celsius — Fahrenheit shows 212. Type 32 in Fahrenheit — Celsius shows 0. This is the classic "lifting state up" exercise — it forces you to keep a single source of truth and derive everything from it.

⚠️ Common mistakes

The state bugs that catch every beginner.

1. Mutating state directly

items.push(x) or user.name = 'Bob' doesn't trigger a re-render. Fix: setItems([...items, x]) or setUser({...user, name: 'Bob'}).

2. Using stale state in handlers

setCount(count + 1) called three times adds 1, not 3. Fix: setCount(prev => prev + 1). The updater form always gets the latest value.

3. Reading state right after setState

setCount(5); console.log(count); logs the OLD value. Fix: state is async. The new value is available next render. Use useEffect to react to changes.

4. Calling hooks conditionally

if (x) { useState(0) } breaks React. Fix: call all hooks unconditionally at the top of the component. Hooks must run in the same order every render.

5. Initializing useState with a function call

useState(getInitialValue()) calls the function on every render. Fix: useState(getInitialValue) (no parens) — React only calls it once.

6. Too much state in one variable

const [state, setState] = useState({ name, age, email, address, ... }) — every update re-renders for all changes. Fix: split into separate useState calls by concern.

7. Duplicating state from props

const [name, setName] = useState(props.name) — if props.name changes, state won't update. Fix: use props directly, or derive with useMemo.

8. Setting state during render

Calling setCount(count + 1) outside an event handler or effect causes infinite re-renders. Fix: state updates belong in event handlers, effects, or updater callbacks.

❓ Frequently asked questions

What is state in React?

State is data that a component owns and can change over time. When state changes, React re-renders the component with the new value. Unlike props (which come from the parent and are read-only), state is managed inside the component itself.

What does useState return?

useState(initialValue) returns an array with two items: the current state value and a setter function. Destructure them: const [count, setCount] = useState(0). The setter updates the state and triggers a re-render.

Why do I need to use useState instead of a regular variable?

A regular variable would reset on every render, and changing it wouldn't trigger a re-render — so the UI wouldn't update. useState persists the value across re-renders AND triggers a re-render when you call its setter.

What does it mean that setState is asynchronous?

Calling setCount(5) doesn't immediately change count. React schedules the update and batches it with other pending updates. The new value is available on the next render. If you log count right after calling setCount, you'll see the old value.

What is the updater function form of setState?

setCount(prev => prev + 1) instead of setCount(count + 1). The updater receives the current state and returns the new state. This is important when calling setState multiple times in a row — because setCount(count + 1) uses the stale value on each call.

How do I update an object in state?

Spread the old object and override properties: setUser({ ...user, name: 'Bob' }). Never mutate: user.name = 'Bob' — this doesn't trigger a re-render because React compares state by reference. For arrays, use setItems([...items, newItem]) or setItems(items.filter(...)).

Can I call useState conditionally?

No. Hooks must be called in the same order on every render. Writing if (x) { useState(0) } breaks React. Fix: call all hooks at the top of the component unconditionally. The eslint-plugin-react-hooks plugin catches this automatically.

What is lifting state up?

When two or more components need to share state, move the state to their closest common parent. Pass the state and its setter down as props. This is the standard React pattern for shared state — it keeps data flowing one direction and makes the app predictable.

🔗 Continue your React path

🌐 External resources

← Back to React track
You're in the React track · Lesson 3