Prior to React Server Components (RSC) and the Next.js App Router, client-side state management libraries (Redux, MobX, Zustand) dominated frontend application architecture. Engineering teams routinely mirrored almost all server payloads into massive client stores: raw database rows, external API DTOs, transient form inputs, and modal toggles. This approach inevitably led to bloated JavaScript bundle sizes, double-caching overhead, and painful cache invalidation bugs.
In modern Next.js architecture, the core principle is unequivocal: server data should stay on the server. Fetching data within Server Components eliminates the need for intermediary global client stores to pass data down the component tree; components receive data via typed props or leverage native request memoization.
However, state management has not vanished—it has organized into distinct layers of responsibility:
In Next.js, application state is partitioned into four distinct categories:
┌─────────────────────────────────────────────────────────┐
│ DATA SOURCES │
└──────────────┬───────────────────────────┬──────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ SERVER STATE │ │ URL STATE │
│ PostgreSQL / Redis / ORM│ │ ?category=tech&page=2 │
└──────────────┬───────────┘ └─────────────┬────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ NEXT.JS APP ROUTER (Server Component) │
│ const { category } = await searchParams; │
│ const data = await db.query(category); │
└────────────────────────────┬────────────────────────────┘
│ props / initialData
▼
┌─────────────────────────────────────────────────────────┐
│ CLIENT HYDRATION & INTERACTION LAYER │
│ │
│ ┌────────────────────────┐ ┌───────────────────────┐ │
│ │ Zustand Store (UI) │ │ React 19 useOptimistic│ │
│ │ (Cart, Modals, Canvas) │ │ (Instant Likes/Chat) │ │
│ └────────────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────┘Classify data origin: persistent database entity vs transient client interaction.
If state affects bookmarking, link sharing, or SEO (filters, paging), store it in searchParams / nuqs.
For instant user response on mutations (toggling likes, chat messages), connect React 19 useOptimistic.
For complex client-only state, instantiate a Zustand Per-Request Store Factory.
Inject client providers via an AppProviders component without converting root layout into a Client Component.
A team builds an e-commerce cart using Zustand by exporting a singleton instance export const useCartStore = create(...) directly in cart-store.ts. In local development, everything functions properly. However, under high production traffic, users occasionally see items belonging to other users' carts upon page reload. What is the root cause?
searchParams and Suspense Boundary#In Server Component pages, accessing search parameters is strictly asynchronous:
import { Suspense } from 'react';
import { SearchFilterInput } from './_components/SearchFilterInput';
import { ProductList } from './_components/ProductList';
interface CatalogPageProps {
searchParams: Promise<{ q?: string; page?: string }>;
}
export default async function CatalogPage({ searchParams }: CatalogPageProps) {
const { q = '', page = '1' } = await searchParams;
return (
<main className="max-w-6xl mx-auto p-6 flex flex-col gap-6">
<h1 className="text-2xl font-bold">Product Catalog</h1>
{/* Wrapping the client input in Suspense prevents de-optimizing the entire page */}
<Suspense fallback={<div className="h-10 bg-bg-surface animate-pulse rounded-lg" />}>
<SearchFilterInput initialQuery={q} />
</Suspense>
<ProductList query={q} page={parseInt(page, 10)} />
</main>
);
}On the client, synchronizing useSearchParams() with useTransition ensures non-blocking UI rendering:
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import { useTransition } from 'react';
export function SearchFilterInput({ initialQuery }: { initialQuery: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const [isPending, startTransition] = useTransition();
const handleSearch = (term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('q', term);
params.set('page', '1');
} else {
params.delete('q');
}
startTransition(() => {
replace(`${pathname}?${params.toString()}`);
});
};
return (
<div className="relative">
<input
defaultValue={initialQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search products..."
className="w-full px-4 py-2 rounded-lg bg-bg-surface border border-border focus:border-accent-violet outline-none"
/>
{isPending && (
<span className="absolute right-3 top-2.5 text-xs text-accent-violet font-medium animate-pulse">
Searching...
</span>
)}
</div>
);
}useOptimistic Hook#The useOptimistic hook eliminates perceived network latency: the UI updates instantly on interaction and automatically rolls back if the Server Action fails.
'use client';
import { useOptimistic, useTransition } from 'react';
import { toggleLikeAction } from '../server/actions';
interface LikeButtonProps {
postId: string;
initialLikes: number;
initialIsLiked: boolean;
}
export function LikeButton({ postId, initialLikes, initialIsLiked }: LikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimisticState, setOptimisticState] = useOptimistic(
{ likes: initialLikes, isLiked: initialIsLiked },
(state, nextLiked: boolean) => ({
likes: nextLiked ? state.likes + 1 : state.likes - 1,
isLiked: nextLiked,
})
);
const handleClick = () => {
const nextValue = !optimisticState.isLiked;
startTransition(async () => {
// 1. Instant optimistic UI update
setOptimisticState(nextValue);
// 2. Invoke Server Action
await toggleLikeAction(postId, nextValue);
});
};
return (
<button
onClick={handleClick}
disabled={isPending}
className={`px-4 py-2 rounded-lg border font-medium transition-colors ${
optimisticState.isLiked
? 'bg-accent-violet/20 border-accent-violet text-accent-violet'
: 'bg-bg-surface border-border text-text-secondary'
}`}
>
❤️ {optimisticState.likes}
</button>
);
}layout.tsx (The Donut Pattern)#Client context providers should never force the root layout.tsx to become a Client Component. Encapsulate client providers in a dedicated wrapper:
'use client';
import { ReactNode } from 'react';
import { CartStoreProvider } from '@/entities/cart/ui/CartStoreProvider';
export function AppProviders({ children }: { children: ReactNode }) {
return (
<CartStoreProvider>
{children}
</CartStoreProvider>
);
}In src/app/layout.tsx, server-rendered children pass transparently through the provider boundary:
import { AppProviders } from '@/shared/providers/AppProviders';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-bg-primary text-text-primary">
<AppProviders>
{children}
</AppProviders>
</body>
</html>
);
}nuqs#For complex filtering and facet exploration, nuqs provides end-to-end type safety and validated state parsing:
'use client';
import { useQueryState, parseAsStringLiteral } from 'nuqs';
const categories = ['all', 'electronics', 'clothing', 'books'] as const;
export function CategorySelect() {
const [category, setCategory] = useQueryState(
'category',
parseAsStringLiteral(categories).withDefault('all')
);
return (
<select
value={category}
onChange={(e) => setCategory(e.target.value as typeof category)}
className="px-3 py-2 rounded-lg bg-bg-surface border border-border"
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
);
}On an e-commerce catalog page, a client component SearchFilter invokes useSearchParams() to filter items. During pnpm run build, Next.js logs a Client-side Deopt warning and disables static pre-rendering for the route. How do you restore static generation for the page?
Exporting a store instance declared at the top-level module scope (export const useStore = create(...)) works in local development, but on Node.js servers, modules are cached and shared across concurrent user requests. If user session data or private cart state is written to this singleton during SSR, subsequent requests will receive another user's sensitive data.
Solution: Always instantiate stores using a factory function (createStore()) scoped inside a React Context and held in a useRef.
When a Client Component calls useSearchParams(), Next.js cannot determine parameter values at build time. Without a <Suspense> wrapper, the entire parent route de-optimizes into dynamic client-side rendering (Client-side Deopt), degrading Core Web Vitals and TTFB.
Solution: Always wrap any client component that consumes useSearchParams() in a <Suspense fallback={<Skeleton />}> boundary.
Zustand's persist middleware reads from localStorage. During SSR, the server produces markup using the default initial state (e.g. theme: 'dark'), while the client hydrates with stored data (e.g. 'light'). React flags this DOM discrepancy and throws a Hydration Mismatch error.
Solution: Use useSyncExternalStore with a fallback server snapshot or defer rendering client-stored UI until after initial component mount.
Fetching records on the server and immediately syncing them into Zustand or Redux via useEffect(() => store.setProducts(products), []) is an anti-pattern. It inflates browser memory, triggers secondary re-renders, and leads to stale client caches. Keep server data in Server Components.