Bitlyst

How useState Works Under the Hood (and Why It Needs use client)

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 new memoizedState.
      • Returns a stable dispatch function.

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 with 3.
  • Updater function: setCount(c => c + 1) → React calls it with the previous state.
  • Lazy init (only on mount): useState(() => heavyInit()) runs heavyInit() 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 stale x. 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.

How did you like this post?

👍0
❤️0
🔥0
🤔0
😮0
How useState Works Under the Hood (and Why It Needs use client) · Bitlyst