Skip to content
Chapter 24Lesson 1

The useState surface and lazy initialization

The first React hook for holding values that change over time, and the judgment that decides when it is the right home.

Every component eventually needs to hold a value that changes over time and drives what the user sees: a counter, a toggle, the currently selected tab, a half-typed form field. In the previous chapter you met the answer to that need, useState, described there as “the setter that schedules a re-render.” That was a three-sentence primer, enough to follow the render model. This lesson installs the full surface.

The surface is small. There is one function, it returns two things, and you already know roughly what they do, so you could memorize the API in a minute. The judgment around it takes longer to learn, because useState is the first hook everyone learns and the one they reach for by reflex, long before they ask whether they should. Four questions decide whether a useState call is right or quietly wrong: what is its type, does its initializer run every render or once, does reading a prop into it freeze that prop, and, the question that comes before all the others, does this value belong in state at all.

By the end you’ll read any useState(...) call and answer all four at a glance. We’ll lean on three facts you already know from the render model: state is a snapshot, the setter only asks for a new render, and updates produce new references rather than mutations. This lesson applies those facts at the call site rather than deriving them again, and closes by mapping out the other homes a value can live in, because half of what beginners put into useState belongs somewhere else entirely.

The signature: a snapshot and a stable setter

Section titled “The signature: a snapshot and a stable setter”

Here is the whole surface in one line:

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

useState returns a two-element tuple: the current value and a function that updates it. That’s it. Everything else is naming and timing.

Read the destructuring carefully, because it explains why the names are yours to choose. const [count, setCount] = ... is array destructuring: it pulls items out by position, not by key. The first slot is the value, the second is the setter, and the names you bind them to are arbitrary. Object destructuring would force you to match property names, but array destructuring leaves the names open, which is why every codebase can settle on a naming convention instead of being handed a fixed API name.

That convention is worth following exactly, because readers rely on it. The value gets a noun, and the setter is set plus that same noun: setCount, setUser, setIsOpen. Booleans read as predicates, so an open/closed flag is isOpen with a setIsOpen, never open. When you see setUser anywhere in a file, you know without scrolling that a user lives nearby in state.

The timing is where the judgment starts. There are three facts to hold about this single line.

The initializer is a mount concern. useState(0) uses 0 on the first render only, the moment the component mounts . On every render after that, React already holds the current value and ignores the argument entirely. This matters more than it looks, because the argument is still evaluated on every update and then thrown away. For 0 that costs nothing, but for heavier work it’s the seed of a real problem we’ll fix two sections from now.

The setter is stable across renders. setCount is the same function reference on render 1, render 50, and render 500. React guarantees this; you don’t have to arrange it. It pays off in two places you’ll meet later: a stable function can sit in an effect’s dependency list without causing the effect to re-run (the next chapter’s subject), and React’s compiler leans on stable identities to memoize correctly. One sentence each is enough for now; just file the guarantee away.

The value is a snapshot, and the setter only asks for a new render. count is this render’s snapshot , frozen for the life of this render. Calling setCount doesn’t change count in place; it schedules a re-render with a fresh snapshot. You already saw the consequence in the previous chapter: three setCount(count + 1) calls in a row increment by one, not three, because all three read the same frozen count. That snapshot rule holds at every useState call site, which is why the updater form setCount((c) => c + 1) exists. It reads the latest queued value instead of the stale snapshot.

Here is the canonical counter with each piece labeled. Hover the underlined tokens.

import { useState } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Clicked {count} times
</button>
);
};

That one block carries the entire signature. Everything that follows is a decision layered on top of it.

Typing useState: inference by default, annotate on purpose

Section titled “Typing useState: inference by default, annotate on purpose”

useState is fully typed, and most of the time you write no annotation at all. The initial value is the type signal:

const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [isOpen, setIsOpen] = useState(false); // boolean

Inference reads 0 as number, '' as string, false as boolean, and the setter is typed to accept exactly that. This is the common case, so prefer it: don’t reach for useState<number>(0). The annotation only repeats what the initial value already says. The course’s default is to let inference lead at the boundaries and add annotations only at the seams.

The seams are the four cases where the initial value can’t represent the full range the state will hold. This is where beginners trip, and the first case below is the most common useState typing bug.

The clearest example is the empty array. useState([]) looks innocent but infers never[], an array that can hold nothing. The moment you try to add an item, TypeScript rejects it, because never[] is exactly the type of “an array with no possible element type.” Compare the two versions:

const [todos, setTodos] = useState([]);
// Type error: Argument of type 'Todo' is not
// assignable to parameter of type 'never'.
setTodos([...todos, newTodo]);

Type error. [] gives TypeScript nothing to infer from, so it lands on never[], an array that can never hold an element. todos is unusable the moment you add to it.

The fix generalizes. Whenever the initial value is a placeholder that doesn’t carry the full type, pass the type to useState directly:

const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<Status>('idle');

useState(null) on its own would infer null, a state that can only ever be null, which is useless for a value that will later hold a User. The User | null annotation is honest about both phases, and because the project runs strict, it forces a null-check wherever you read user, which is exactly what you want.

The status line is the mirror image. useState('idle') infers string, which is too wide this time: the setter would accept any string at all, including typos. Annotating useState<Status>('idle'), where Status is the union 'idle' | 'loading' | 'done', pins the setter to the three legal values. (You met literal unions back in the TypeScript chapters; this is the same idea at a hook call site.)

The whole typing rule fits in one sentence:

Try sorting these. For each call, decide whether the initial value already carries the full type or whether it’s too narrow.

Sort each `useState` call by whether it needs an explicit type annotation. Ask: can the initial value represent the full range the state will hold? Drag each item into the bucket it belongs to, then press Check.

Let inference win Initial value is the full type
Annotate the type Initial value is too narrow or null
useState(0)
useState('')
useState(false)
useState({ x: 0, y: 0 })
useState([])
useState(null)
useState('idle')

Recall the timing fact from the signature section: the initial argument is evaluated on every render and discarded on every render after the first. For useState(0) that’s free. But look at what happens when the initializer does real work.

const [draft, setDraft] = useState(parseDraft(getStoredDraft()));

Here getStoredDraft() reads from localStorage and parseDraft turns the raw string into an object. Both are function calls sitting in the component body, so they run as part of rendering, on every single render, then hand their result to a useState that keeps only the first. When the user types in some other field, the component re-renders and the read-and-parse runs again for nothing. That’s wasted work on every keystroke.

The fix is one of React’s small, sharp distinctions: pass a function instead of a value.

const [draft, setDraft] = useState(() => parseDraft(getStoredDraft()));

When React sees a function in the initializer slot, it treats it as an initializer function and calls it once, on mount. Later renders never touch it. The contrast is sharp:

  • useState(parseDraft(...)) calls parseDraft now, on every render. The parens run the work immediately, and React keeps the result only the first time.
  • useState(() => parseDraft(...)) hands React a function it runs once. The () => defers the work to mount.

You’ve seen this “pass a function, don’t call it” shape before: it’s the same move as the updater form setCount((c) => c + 1). A bare value is consumed immediately, while a function is consumed only when React decides to run it.

const [draft, setDraft] = useState(parseDraft(getStoredDraft()));

Runs parseDraft on every render and keeps only the first result. The read-and-parse repeats on every unrelated keystroke.

The decision, rather than the syntax, is the harder part: when is the lazy form worth it? Wrapping every initializer in () => would be needless ceremony, since useState(() => 0) buys nothing and the literal is already free. The lazy form earns its weight only past a clear threshold. Reach for it when the initializer:

  • touches storage, like localStorage or sessionStorage,
  • parses something, like JSON, a query string, or anything that walks input,
  • builds a large structure, like indexing an array or deriving a lookup map,
  • measures, like reading layout off the DOM.

For a literal or a cheap expression (useState(0), useState(props.count ?? 0)), the direct form is correct and the lazy wrapper only adds noise. This follows the same instinct that runs through the whole chapter: you pay for the lazy form only when a concrete cost crosses the threshold, never as a precaution.

Two more things belong with the initializer itself, since both are properties of how it runs.

The initializer must be pure. This is the same contract as the render-model chapter: no side effects, just compute and return. React’s Strict Mode deliberately calls it twice in development to surface impure initializers, so anything that mutates or logs will behave unexpectedly. (The mechanism behind that double-call is the next chapter’s subject; for now, just keep the initializer pure.)

Storing a function as state needs a double wrap. This is the one place the mechanism catches people out. Suppose you want to keep a function itself in state, say an onSubmit callback you’ll swap out later:

const [handler, setHandler] = useState(onSubmit);

This calls onSubmit() once and stores its return value. React treats any function in the initializer slot as an initializer to run, not a value to keep.

Storing a raw function in state is rare in practice, but it’s the one corner where “React treats a function as an initializer” produces a result you didn’t ask for, so it’s worth recognizing.

To make the gap between once and every render concrete, predict what this prints. It’s a plain-JavaScript model of useState: a state slot keeps its value after the first render, and the loop runs three times to stand in for three renders of a component.

This plain-JavaScript model stands in for `useState`: `useStateSlot` keeps its value after the first render and ignores its argument on every render after. Predict what this program prints, then press Check.

let isMounted = false;
let slot;
function useStateSlot(initial) {
if (!isMounted) {
isMounted = true;
slot = typeof initial === 'function' ? initial() : initial;
}
return slot;
}
function expensive() {
console.log('expensive ran');
return 42;
}
// Three renders, eager form: the argument is built every time.
isMounted = false;
console.log('eager:');
for (let r = 0; r < 3; r++) useStateSlot(expensive());
// Three renders, lazy form: the function is only called on mount.
isMounted = false;
console.log('lazy:');
for (let r = 0; r < 3; r++) useStateSlot(() => expensive());

Reading a prop as initial state: the frozen copy

Section titled “Reading a prop as initial state: the frozen copy”

Here’s a pattern that looks completely reasonable and trips up nearly everyone.

const PriceInput = ({ defaultPrice }: { defaultPrice: number }) => {
const [price, setPrice] = useState(defaultPrice);
// ...
};

You seed the state from a prop. It works on first render. Then the parent changes defaultPrice, and the input keeps showing the old price. The state never moved.

There’s no bug in React here. This is the initializer rule you already know, applied to a prop: useState(defaultPrice) reads defaultPrice on the first render only. After mount, React owns price and ignores the initializer, so the prop and the state drift apart the instant either changes. The prop is a seed, not a subscription.

The question that matters is not how to sync them, but whether they should be synced at all. The answer is encoded in the prop’s name, a tell an experienced engineer reads instantly.

const PriceInput = ({ defaultPrice }: { defaultPrice: number }) => {
const [price, setPrice] = useState(defaultPrice);
// The user edits `price` freely; it should diverge.
};

Editable copy, and correct. The default prefix is a promise: this prop seeds the field once, then the user owns it. React and HTML both use default* to mean exactly this.

The naming convention is doing real work. A prop named defaultValue, defaultPrice, or defaultOpen carries a contract: I seed you once and won’t track you. You’ve seen defaultValue on HTML inputs before, the same idea and the same word. That’s the uncontrolled shape, and freezing a default* prop into state is the deliberate, correct version of it.

A prop named plainly, like value, price, or selectedId, implies the opposite contract: I am the source of truth; render me. That’s a controlled value, and freezing it into local state silently breaks the contract, because the child stops following its own source of truth. The fix beginners reach for is a useEffect that copies the prop into state on every change. Resist it: it’s the most consequential anti-pattern in early React, and dismantling it is the entire subject of the next lesson.

There are two real fixes, and you’ve already met one:

  • If the value is purely a function of the prop, derive it during render and don’t store it at all. That’s the next lesson.
  • If it’s an editable copy that should reset when the prop’s identity changes, reach for the key-reset you already saw: key={record.id} remounts the child with a fresh seed when the record changes.

You’ve installed the surface and the three judgment calls that ride on it. One decision is left, and it’s the one that comes first in practice: before you reach for useState at all, ask what kind of value you’re actually holding. useState is the right home for many values, and quietly the wrong home for many others.

This idea runs through the whole chapter: state shape is a design decision before it’s a syntax decision. useState is one home among several. Run any value you’re about to store through this filter:

%%{init: {'themeCSS': '.node.home .nodeLabel { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }'} }%%
flowchart LR
  start([A value your<br/>component needs])
  q1{"Does the<br/>JSX read it?"}
  q2{"Computed from<br/>other state<br/>or props?"}
  q3{"Shared by 2+<br/>components?"}
  q4{"Survive refresh,<br/>shareable, or<br/>server-canonical?"}

  ref["<b>useRef</b><br/>escape hatch — L5"]
  derive["<b>Derive in render</b><br/>next lesson — L2"]
  lift["<b>Lift to parent</b><br/>later this chapter — L3"]
  url["<b>URL or server state</b><br/>later chapters"]
  usestate["<b>useState ✓</b><br/>the default home"]

  start --> q1
  q1 -- No --> ref
  q1 -- Yes --> q2
  q2 -- Yes --> derive
  q2 -- No --> q3
  q3 -- Yes --> lift
  q3 -- No --> q4
  q4 -- Yes --> url
  q4 -- No --> usestate

  class ref,derive,lift,url offramp
  class usestate home
  classDef offramp fill:#1f2937,stroke:#94a3b8,color:#f8fafc
  classDef home fill:#bbf7d0,stroke:#15803d,color:#111,stroke-width:2px
The four homes for state. useState is the default at the leaf, and every arrow that turns off before it points at a value that belongs somewhere else.

Walk it in words, because each branch names a home the rest of the chapter will fill in:

  1. Does the JSX read it, and does it change over time on its own? Then it’s useState. The number on a counter, whether a dropdown is open, the active tab: these drive what’s painted and change independently. This is the default, and it’s what this lesson taught.
  2. Is it computed from other state or props? Then derive it in render rather than storing it. A cart total is just the sum of its line items; the count of completed todos is just a .filter().length. Storing these creates two sources of truth that can disagree. (Next lesson.)
  3. Does it persist across renders but the UI never reads it? A setTimeout ID a handler needs to clear, a <video> element you call .play() on, the previous value of something: these belong in useRef , not state. Changing them shouldn’t repaint anything, and useState would force a render you don’t want. (Later this chapter.)
  4. Do two or more components need it? Then lift it to their common parent and pass it down. A search query that two sibling panels both read lives in the parent, not duplicated in each. (Later this chapter.)
  5. Should it survive a refresh, or be shareable as a link? Then it’s URL state: the active filter, the current page, a search term you’d want to bookmark. And if it’s the canonical record on your server, like the actual list of invoices, that’s server state, fetched and cached, never copied into long-lived useState. (Later chapters.)

The instinct underneath all five is to start at the leaf with useState, and move a value outward only when a concrete trigger demands it. Don’t lift preemptively, and don’t reach for the URL “just in case.” Keep state close to where it’s used, and relocate it only when you have a reason.

Sort these values into their proper home. A couple are deliberately tempting.

Decide where each value belongs. Walk the filter: does the JSX read it, is it derived, is it shared, should it survive a refresh? Drag each item into the bucket it belongs to, then press Check.

useState Local, read by JSX, changes on its own
useRef Persists, but the JSX never reads it
Derive in render Computed from other state or props
Lift or URL state Shared by siblings, or survives a refresh
The number shown on a counter
Whether a dropdown is open
A setTimeout ID a handler clears
A <video> element you call .play() on
A cart’s line-item total
The count of completed todos
A search query two sibling panels both read
The active filter you want to survive a page refresh

That filter is the map for the rest of this chapter. The next lesson takes the derive branch and works through the anti-pattern that comes from getting it wrong; the lessons after that take lift, URL, and the useRef escape hatch in turn. You now know where each value lives, and that useState was only ever one of the homes.

The useState reference is worth a bookmark, since it’s the canonical source for the lazy initializer and the function-as-value gotcha. The “Choosing the State Structure” guide goes deeper on the “what belongs in state” question this lesson opened and the next one closes. Kent C. Dodds’ note pairs the lazy initializer with the updater form in one place, and “You Might Not Need an Effect” is the official case against the prop-syncing reflex this lesson warns you off.