1) Where state actually lives
- React builds a tree of Fibers (one per component instance).
- Each function component has a hook list stored on its fiber (a tiny linked list/array-like structure).
- Every call to
useState(...)
corresponds to one hook cell in that list. Order must be stable between renders.
2) Mount vs. update
-
Mount (first render):
- React creates a hook cell with:
-
memoizedState
: current value (init arg or result of lazy init function). -
queue
: a queue of pending updates (linked list). - Returns
[state, dispatch]
.
-
Update (re-render):
- React walks the hooks in the same order and for each
useState
: - Drains the
queue
of updates, applying them in order to produce the newmemoizedState
. - Returns a stable
dispatch
function.
- React walks the hooks in the same order and for each
Order matters: React identifies each hook by call position in the component. Changing the number/order of hooks between renders breaks this mapping.
3) What setState
(dispatch) really does
- Calling
setState(payload)
does NOT mutate immediately. - It creates an update object and pushes it to the hook’s
queue
. - React schedules work on the affected fiber (with an appropriate priority).
- During the next render, React reduces all queued updates to compute the next state.
Types of payloads
-
Value:
setCount(3)
→ replace with3
. -
Updater function:
setCount(c => c + 1)
→ React calls it with the previous state. -
Lazy init (only on mount):
useState(() => heavyInit())
runsheavyInit()
once.
4) “Async” feel and batching
- In React 18+, updates in the same tick are batched (browser events, promises, timeouts, etc.).
- You might not see new state until the render that processes the queue finishes.
-
flushSync
can force-sync (use sparingly).
5) Concurrency & priorities (React 18)
- React can interrupt a render (e.g., low-priority updates) and resume with fresher data.
-
startTransition
marks updates as transition (lower priority) so typing stays snappy. - State queues make this possible because React can re-run the reducer pipeline deterministically.
6) Closures & gotchas
- Handlers capture variables from the render in which they were created.
If you use
setX(x + 1)
inside async code, you may use a stalex
. Prefer the functional form:setX(prev => prev + 1)
. - Never call hooks in loops/conditions. Keep call order stable.
Why useState
requires "use client"
in Next.js App Router
Server Components (RSC) render on the server. They:
- Must be serializable output only (JSX/JSON-like payload).
- Cannot hold runtime client state, attach event handlers, or access the DOM.
- Don’t ship their code to the browser (no client JS).
useState
, useEffect
, event handlers (onClick
), etc. require a client runtime (browser or React DOM on the client). Therefore:
- Any file that uses
useState
must start with:
"use client";
This marks the file as a Client Component so:
- It’s bundled for the browser.
- It can run hooks, keep state across renders, handle events.
- It cannot import server-only modules (DB clients,
fs
, server actions directly, etc.).
Mental model
- Server Component = fetch/compose data, no stateful UI hooks, zero client JS by default.
- Client Component = interactive UI (state, effects, refs, event handlers).
Typical pattern (server → client boundary)
// app/posts/page.tsx (Server Component by default)
import PostList from "./PostList";
export default async function Page() {
const posts = await fetchPosts(); // server-side data
return <PostList initialPosts={posts} />;
}
// app/posts/PostList.tsx
"use client";
import { useState } from "react";
export default function PostList({ initialPosts }) {
const [filter, setFilter] = useState("");
const visible = initialPosts.filter(p => p.title.includes(filter));
return (
<>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<ul>{visible.map(p => <li key={p.id}>{p.title}</li>)}</ul>
</>
);
}
- The server loads data cheaply.
- The client component handles state & interactivity.
- Only
PostList
(and its client-only deps) are sent to the browser.
Practical tips
- Prefer functional updates when next state depends on previous:
setCount(c => c + 1);
- Use lazy init for expensive initial state:
const [value] = useState(() => computeOnce());
- Avoid deriving state you can compute from props during render; compute on the fly or memoize.
- If a component only needs state for a tiny part, consider a small client child instead of marking a whole page
"use client"
. - Remember: every
useState
call is per-instance. Each mounted component instance has its own hook cells & queues.
Originally published on: Bitlyst
Top comments (0)