BitlystBitlyst
PostsSeriesTopicsAbout

Bitlyst

Short, diagram-first posts on JavaScript, React & Next.js. Follow via RSS or the newsletter.

© 2026 Mohsen Fallahnejad

PostsSeriesTopicsLinksAboutRSSGitHubX
Posts/react
Intermediate4 min read

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.

Mohsen Fallahnejad

Mohsen Fallahnejad

Sep 23, 2025Discuss

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:
    tsx
    "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)#

tsx
// 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} />;
}
tsx
// 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:
    ts
    setCount(c => c + 1);
    
  • Use lazy init for expensive initial state:
    ts
    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.
Filed under#react#hooks#useState#nextjs#use client

Was this worth your time?

One tap tells me what to write more of.

Mohsen Fallahnejad

Written by

Mohsen Fallahnejad

Frontend engineer explaining JavaScript, React & Next.js internals in posts you can finish over coffee. More about me

X ↗GitHub ↗LinkedIn ↗
← OlderJavaScript Closures — A Simple Deep DiveNewer →AbortController & AbortSignal

Start the discussion

Questions, corrections, a better approach? I read and reply to every comment.

Read next

React useRef vs useState — When to Use Which

A simple guide to understanding when to use useRef and useState in React, with examples, mental models, and a quick cheatsheet.

Sep 17, 20252 min read

The Server Action That Ran Twice, but Only on localhost

Why a Next.js Server Action called from useEffect runs twice in development: React Strict Mode mounts every component twice. What that reveals, and where the call belongs.

Nov 3, 20264 min read

I Optimized a Button From O(n) to O(1). The Speed Didn't Matter.

A bug report, a route map, and three rounds of making it faster — a story about what Big O really measures, why constants win at small n, and why the best algorithm is sometimes deleting the question.

Oct 31, 202610 min read6

Get new posts in your inbox

No spam. Unsubscribe any time.

4 min left
Discuss👍 React
👍