Bitlyst

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

Deep dive into React's useState, how it works internally, and why client components are required in Next.js App Router.

4 min read#react#hooks#useState#nextjs#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:
    code
    "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)

code
// 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} />;
}
code
// 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:
    code
    setCount(c => c + 1);
    
  • Use lazy init for expensive initial state:
    code
    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.

You made it to the end!

Did this help? Leave a reaction — it takes one second.

 

Got feedback? 💬

Typo, suggestion, question — I read every message.

Comments

Mohsen Fallahnejad
Mohsen Fallahnejad

Writing bite-sized JS, React & Next.js tips

Get new posts in your inbox

No spam. Unsubscribe any time.