Skip to content
Chapter 24Lesson 1

The useState surface and lazy initialization

React's first 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 selected tab, a half-typed form field. The previous chapter introduced useState as the answer, “the setter that schedules a re-render” — enough to follow the render model, but not the whole tool.

useState is the first hook everyone learns and the one they reach for by reflex, often before asking whether they should. Four questions decide whether a given call is right: what is its type, does its initializer run every render or once, does reading a prop into it freeze that prop, and, before all the others, does this value belong in state at all? That last question matters most, because half of what beginners reach for useState to hold 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.

const [count, setCount] = ... is array destructuring, so it pulls items out by position, not by key: the first slot is the value, the second is the setter. Object destructuring would force you to match property names, but positions carry no names, so the names you bind are yours to choose. That freedom is why every codebase settles on a naming convention instead of a fixed API name.

Follow the convention 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 setIsOpen, never open. When you see setUser anywhere in a file, you know a user lives nearby in state without scrolling.

Three facts about timing govern how you use this 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. The catch is that the argument is still evaluated every render and then thrown away. For 0 that costs nothing, but for heavier work it’s the seed of a 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, so you never have to arrange it. File the guarantee away: a stable identity is what lets the setter sit in an effect’s dependency list without re-running it, and lets React’s compiler memoize correctly.

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 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. The 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>
);
};

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 types the setter to accept exactly that. Prefer it: useState<number>(0) only repeats what the initial value already says.

Annotate at the seams, 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.

Start with the empty array. useState([]) looks innocent but infers never[], an array that can hold nothing. The moment you add an item, TypeScript rejects it, because never[] is exactly “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) infers 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.

The status line is the mirror image. useState('idle') infers string, too wide this time: the setter would accept any string, typos included. useState<Status>('idle'), where Status is 'idle' | 'loading' | 'done', pins the setter to the three legal values.

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')

The signature section noted that the initial argument is evaluated on every render and discarded after the first. For useState(0) that’s free. It stops being free 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 in the component body, so they run on every render and hand the result to a useState that keeps only the first. Type in any other field and the read-and-parse runs again for nothing: wasted work on every keystroke.

The fix is to 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.

You’ve seen this “pass a function, don’t call it” shape in the updater form setCount((c) => c + 1): a bare value is consumed immediately, a function only when React runs 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.

When is the lazy form worth it? useState(() => 0) buys nothing over useState(0), so the wrapper 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 wrapper only adds noise.

Two more rules follow from how the initializer runs.

The initializer must be pure. Same contract as the render-model chapter: no side effects, just compute and return. Strict Mode calls it twice in development to surface impure initializers, so anything that mutates or logs misbehaves (the next chapter covers why).

Storing a function as state needs a double wrap. Suppose you want 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, but it’s the one corner where “React treats a function as an initializer” produces a result you didn’t ask for.

Predict what this plain-JavaScript model of useState prints. The state slot keeps its value after the first render, and the loop runs three times to stand in for three renders.

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());

Seeding state from a prop freezes it at mount

Section titled “Seeding state from a prop freezes it at mount”

Here’s a pattern that looks reasonable but hides a bug.

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

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

This isn’t a React bug; it’s the initializer rule 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 isn’t how to sync them, but whether they should be synced at all. The answer is in the prop’s name.

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, 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 breaks that 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: dismantling that anti-pattern 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.
  • 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.

Before you reach for useState at all, ask what kind of value you’re holding. State shape is a design decision before it’s a syntax decision, and 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; every arrow that turns off earlier points at a value that belongs elsewhere.

Walk it in words, since each branch names a home the rest of the chapter fills in:

  1. Does the JSX read it, and does it change on its own? Then it’s useState. A counter’s number, whether a dropdown is open, the active tab: these drive what’s painted and change independently. This is the default, and what this lesson taught.
  2. Is it computed from other state or props? Then derive it in render instead of storing it. A cart total is the sum of its line items; the count of completed todos is a .filter().length. Storing them 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 clears, 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 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 bookmarkable search term. 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: start at the leaf with useState, and move a value outward only when a concrete trigger demands it. Don’t lift preemptively or reach for the URL just in case. Keep state close to where it’s used, and relocate it only when you have a reason.

The useState reference is the canonical source for the lazy initializer and the function-as-value gotcha. “Choosing the State Structure” goes deeper on the “what belongs in state” question this lesson opened. 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.