Your Search Box Fails at Build Time: Where useState Is Allowed to Run
A product page needs a search input that filters results as you type. The page is a Server Component — it awaits a database query and returns JSX — so you add the input right there, with an onChange handler and a useState holding the query. The dev server stops you before the page renders:
You're importing a component that needs useState. It only works in a
Client Component but none of its parents are marked with "use client".
Hooks have no meaning on the server: there is no browser, no persistent React instance, and nothing to re-render after an event. A Server Component renders once to HTML and an RSC payload, is never shipped to the browser, and therefore cannot hold React state. The fix is not to abandon the page — it is to draw the boundary at the smallest interactive file and keep the data fetching exactly where it is:
// app/products/search-box.tsx
'use client';
import { useState } from 'react';
export function SearchBox({ terms }: { terms: string[] }) {
const [query, setQuery] = useState('');
const matches = terms.filter((t) =>
t.toLowerCase().includes(query.toLowerCase())
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='Filter products'
/>
<ul>
{matches.map((t) => <li key={t}>{t}</li>)}
</ul>
</div>
);
}
// app/products/page.tsx — no directive; stays a Server Component
import { SearchBox } from './search-box';
export default async function Page() {
const terms = await db.product.findMany({ select: { name: true } });
return (
<main>
<h1>Products</h1>
<SearchBox terms={terms.map((p) => p.name)} />
</main>
);
}
The directive applies per module, and everything a client module imports becomes client code too — which is why you push 'use client' as far down the tree as it will go. The fetch still runs on the server; only the interaction and the useState closure ship to the browser. Server data fetched via the patterns in HTTP Drivers at the Edge never needs React state at all — it arrives as plain props.
Here is what happens at runtime. The server renders the input with query at its initial value (''), producing static HTML: an empty box plus the full list. The browser downloads the client bundle, React hydrates that markup — attaching your onChange listener — and from that point setQuery re-renders this one component in the browser with no server round trip. Hydration compares the first client render against the server HTML, so an initial value that differs between the two (reading localStorage inside the useState initializer, for example) throws a hydration mismatch error. Keep the first render identical on both sides and let a useEffect catch up with browser-only values.
Local state also has a lifetime tied to the mounted component instance, and in the App Router navigating between routes unmounts the page component. That reset is a feature for transient UI — open a modal, navigate away, come back, and the modal is closed again with zero cleanup code — and a bug the moment users expect continuity. This table is the placement rule this whole phase is built on:
| State you are holding | Survives navigation? | Where it belongs |
|---|---|---|
| Modal open flag, input draft, tab index | No — resets on unmount | useState in the component |
| List filters, page number, sort order | No — but users expect share and Back to work | URL search params |
| Cart, theme preference, wizard draft | No — must outlive any single component | A global store |
| Anything the database owns | Not applicable — refetch instead | Server Component or action |
If the user filters a list, opens a product, presses Back, and the filters are gone, the state was stored in the wrong place. Keeping Filters in the URL fixes that row of the table. First, though, watch what happens when several local values stop updating independently of each other.
useReducer When Three setState Calls Stop Agreeing With Each Other
A cart component tracks line items and a status flag. Adding an item means updating the items array, recomputing a total, and flipping the status field — three setState calls that must land together. React 18 batches them into one render, but each call still computes its next value from whatever its closure captured, and the transition logic scatters across event handlers. When several updates are facets of one transition, useReducer puts that transition in one place you can read, name, and test.
First move the logic into a pure reducer over explicit action objects:
// lib/cart-reducer.ts
export type CartState = {
items: { id: string; price: number; qty: number }[];
status: 'idle' | 'saving' | 'error';
};
export type CartAction =
| { type: 'add'; id: string; price: number }
| { type: 'setQty'; id: string; qty: number }
| { type: 'clear' };
export function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'add': {
const found = state.items.find((i) => i.id === action.id);
const items = found
? state.items.map((i) =>
i.id === action.id ? { ...i, qty: i.qty + 1 } : i
)
: [...state.items, { id: action.id, price: action.price, qty: 1 }];
return { ...state, items, status: 'idle' };
}
case 'setQty':
return {
...state,
items: state.items.map((i) =>
i.id === action.id ? { ...i, qty: Math.max(1, action.qty) } : i
),
};
case 'clear':
return { items: [], status: 'idle' };
default:
return state;
}
}
Then the component only dispatches:
'use client';
import { useReducer } from 'react';
export function CartLines({ initial }: { initial: CartState }) {
const [cart, dispatch] = useReducer(cartReducer, initial);
return (
<>
{cart.items.map((i) => (
<Line
key={i.id}
item={i}
onQty={(qty) => dispatch({ type: 'setQty', id: i.id, qty })}
/>
))}
<button onClick={() => dispatch({ type: 'clear' })}>Empty cart</button>
</>
);
}
The reducer is a pure function of (state, action): no fetch, no Date.now(), no mutation of the state you were handed. Purity is enforced in development — Strict Mode invokes reducers twice to surface impure ones — and it is what makes the reducer testable without rendering a single component: state and action go in, the next state comes out. The same function later moves unchanged into a Server Action if the cart must persist server-side.
Semantics worth knowing before an interviewer asks:
dispatchdoes not change state immediately; it schedules. React applies queued actions in order during the next render, each reducer call receiving the previous call's output — so five dispatches in one handler chain correctly and still produce one re-render.- Returning the same state object (
default: return state) makes React bail out of re-rendering that component. Use it deliberately for no-op actions. dispatchis stable across renders, so passing it down to children never invalidates a memoized component the way a wrapped callback would.
Choosing between the hooks is a judgment call, not a rule of thumb:
| Signal in your code | Prefer |
|---|---|
| One independent value (a boolean, a string) | useState |
| Next state depends on several previous values | useReducer |
| Same update logic repeated across many handlers | useReducer |
Transitions worth naming (add, remove, save) |
useReducer |
| You want unit tests for the transition logic itself | useReducer |
One thing useReducer does not solve: distribution. If the transitions must fire from components three levels apart in the tree, you no longer have a local-state problem — you have a global-state problem, which the later topics in this phase address.
The Three Local-State Bugs That Surface in Every Code Review
Bug 1: the closure that captured a dead value
A counter that should tick up every second forever shows 1 and stops:
'use client';
import { useEffect, useState } from 'react';
export function Ticker() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000); // wrong
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}
The arrow function captures count from the render in which the effect ran — forever 0 — so every tick computes 1, React sees an unchanged value, and the display never moves. This is a stale closure, and it appears wherever an update is computed inside a deferred callback: intervals, event listeners, promise .then handlers, setTimeout. The fix is the functional updater, which receives the current value at update time:
setCount((c) => c + 1); // always the latest count
dispatch in useReducer already has this property — the reducer reads current state by construction — which is one quiet reason reducers scale better than five chained setState calls. The interval cleanup (clearInterval in the effect's return) stays your responsibility; React no longer warns about updates on unmounted components, but the timer itself keeps running without it.
Bug 2: copying a derived value into state
// wrong: two sources of truth for one value
const [filtered, setFiltered] = useState(items);
useEffect(() => { setFiltered(items.filter(match)); }, [items, match]);
The copy renders stale for one frame after items changes (effects run after paint), it depends on a hand-maintained dependency list that eventually rots, and under concurrent rendering the two sources can disagree. The rule: if a value can be computed from state or props during render, do not store it. Compute it inline — const shown = items.filter(match) — and reach for useMemo only when the computation is genuinely expensive, not to protect a five-item filter.
Bug 3: state whose lifetime is shorter than its job
The App Router unmounts a page component on navigation, so every useState value inside it is gone the moment the user clicks a nav link. Run this placement test on each piece of state you add:
- Would losing it surprise the user? No → local
useState(hover state, modal open, tab index). - Would the user want to share or bookmark this view? → URL search params, covered in Keeping Filters in the URL.
- Must it survive navigation and be read by distant components? → a store such as Zustand, previewed in Global Client State with Zustand.
- Is the server the source of truth? → never mirror it into client state; refetch through a Server Component or action.
The quiet fourth: state initialized from props
const [name, setName] = useState(props.user.name) reads the prop exactly once, at mount. When the parent passes a new user, the state silently keeps the old value and the two disagree from that render onward. useState's first argument is an initializer, not a subscription. If a value is meant to track its prop, read the prop directly; if it is meant to be reset when the input changes, reset it deliberately with key instead of syncing through an effect.
One deliberate escape hatch: the key prop. <SearchBox key={filterId} /> forces a remount when filterId changes, wiping all local state on purpose. That is the clean way to clear a draft when the record being edited changes — no useEffect, no reset() call, and the reset semantics are visible at the call site where the parent decides they should be.
Quiz
1. A Server Component page uses useState directly and the build fails. What is the correct fix?
2. A page component holds useState for an open modal. The user navigates to another route and presses Back. What is the modal's state?
3. Inside useEffect, an interval calls setCount(count + 1) every second starting from 0. What does the component display after five seconds?
4. Which situation is the strongest argument for useReducer over several useState calls?
Flashcards
Question
Why does a component calling useState need 'use client' in the App Router?
Click to reveal answer
Answer
Hooks and event handlers have no runtime on the server: a Server Component renders once to HTML/RSC payload and is never shipped to the browser. 'use client' marks the module as part of the client bundle so Next.js ships it and React hydrates it, while the page around it can stay a Server Component.
Question
What happens to useState values when the user navigates away from a route and comes back?
Click to reveal answer
Answer
They are gone. Local state lives with the mounted component instance, and the App Router unmounts page components on navigation. Anything that must survive belongs in URL search params, a global store, or on the server.
Question
What tips the choice from useState to useReducer?
Click to reveal answer
Answer
Use useReducer when the next state depends on several previous values, when the same update logic is duplicated across handlers, when transitions deserve names (add, setQty, clear), or when you want to unit-test the transition as a pure function without rendering.
Question
What is a stale closure in a state update, and what is the standard fix?
Click to reveal answer
Answer
A deferred callback (interval, listener, promise) captures state values from the render where it was created, so later invocations compute from dead values. The fix is the functional form setCount(prev => ...), which receives the current value at update time; dispatch-based updates already read current state via the reducer.
Question
Why should a computed value like a filtered list never be copied into state?
Click to reveal answer
Answer
The copy is a second source of truth: it renders stale until an effect runs, needs a hand-maintained dependency list, and can disagree with its inputs during concurrent rendering. Compute it during render instead, and reserve useMemo for genuinely expensive work.
Revision Notes
Key Takeaways
- 1. 'use client' marks the smallest interactive module; data fetching stays in the Server Component parent, so the split is server data, client interaction
- 2. Hydration compares the first client render to the server HTML — initial state that reads browser-only storage throws a mismatch error
- 3. Local state dies with the component instance, and page components unmount on every navigation in the App Router
- 4. useReducer centralizes interdependent transitions in a pure, unit-testable function; dispatch is stable and batching chains multiple dispatches into one render
- 5. Derived values are computed during render, never stored, and deferred callbacks always use the functional state-updater form
Interview Tips
- • "Where does state belong in a Next.js app?" — answer with the placement test (component, URL, store, server) and justify each row; interviewers are checking that you reason about lifetime and shareability, not just API knowledge
- • "Explain hydration mismatch without using the word hydration twice" — server renders initial HTML, client must render identically on first pass, then effects reconcile browser-only values; a localStorage read inside useState breaks that contract
- • "What is a stale closure?" — give the setInterval counter example, explain the captured render value, and fix it with setCount(c => c + 1); mention dispatch already reads current state
- • "When would you refuse to use useState?" — computed values (derive during render), server-owned data (refetch), and state that must survive navigation (URL or store); naming all three shows the full decision surface
Cheat Sheet
Local State with useState and useReducer
The boundary rule: hooks require 'use client'; put the directive on the smallest interactive file and leave the page fetching on the server.
Placement test for every piece of state:
| Question | Answer → home |
|---|---|
| Would losing it surprise the user? | No → useState in the component |
| Shareable / bookmarkable view? | URL search params |
| Must survive navigation across components? | Global store (Zustand, Context) |
| Server owns the truth? | Refetch; never mirror into state |
useState vs useReducer:
| Signal | Choice |
|---|---|
| One independent value | useState |
| Next state depends on previous values; repeated update logic | useReducer |
| Want pure, render-free transition tests | useReducer |
Bug checklist:
// stale closure -> functional update
setCount((c) => c + 1);
// derived value -> compute during render, don't store
const shown = items.filter(match);
// deliberate state reset -> remount with a new key
<SearchBox key={filterId} />
Reducer contract: pure function of (state, action); Strict Mode double-invokes it; returning the same state object makes React bail out; dispatch identity is stable across renders.