Client State Management in Next.js 14 Server Components
How to properly manage client-side state when using React Server Components in Next.js 14 App Router
Client State Management in Next.js 14 Server Components
Introduction
React Server Components fundamentally change how we think about state management. In the traditional React model, everything runs on the client, and state management libraries like Redux, Zustand, or Jotai are always available. With Server Components, most of your component tree renders on the server, where these libraries simply cannot run.
This creates a new architectural challenge: how do you manage client-side state in a Server Component world? After building several applications with Next.js 14, I have developed a clear mental model and practical patterns for handling this transition.
Environment
- OS: Windows 11
- Node.js: v20.10.0
- Next.js: 14.0.4
- React: 18.2.0
- State Management: Zustand 4.4.7, React Query 5.17.0
Problem
When attempting to use Zustand inside a Server Component, the following error occurs:
Error: Zustand cannot be used in a Server Component.
Zustand requires client-side JavaScript to function.Even when components are correctly marked with "use client", issues arise with state sharing between Server and Client Components:
// This creates two separate component trees
// Server component cannot pass callbacks to client component
{/* State is isolated here */}
The core problem is that Server Components and Client Components form separate rendering contexts. A state update in a Client Component does not trigger a re-render in a Server Component, and vice versa.
Analysis
The architectural constraints are:
1. Server Components have no state. They are functions that return JSX. There is no useState, no useEffect, no hooks of any kind.
2. Client Components are islands of interactivity. They can use all React hooks, but their state is isolated from the server.
3. The server-client boundary is the fundamental constraint. Data flows from server to client through props, but not the reverse.
4. Third-party state libraries require "use client". Zustand, Redux, Jotai, and similar libraries all need to run in the browser.
Solution
Pattern 1: Lift client state to a dedicated Client Component wrapper
// app/dashboard/layout.tsx - Server Component
import { DashboardHeader } from './DashboardHeader'
import { DashboardContent } from './DashboardContent'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
// Server-side data fetching
const user = await getUser()
const notifications = await getNotifications()
return (
{children}
)
}// app/dashboard/DashboardHeader.tsx - Client Component
'use client'
import { useState } from 'react'
interface DashboardHeaderProps {
user: { name: string; email: string }
notifications: Array<{ id: string; message: string; read: boolean }>
}
export function DashboardHeader({ user, notifications }: DashboardHeaderProps) {
const [showNotifications, setShowNotifications] = useState(false)
const [unreadCount, setUnreadCount] = useState(
notifications.filter((n) => !n.read).length
)
return (
Welcome, {user.name}
{showNotifications && (
{notifications.map((n) => (
- {n.message}
))}
)}
)
}Pattern 2: Zustand store for cross-component client state
// stores/useCartStore.ts
'use client'
import { create } from 'zustand'
interface CartItem {
id: string
name: string
price: number
quantity: number
}
interface CartStore {
items: CartItem[]
addItem: (item: Omit) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
getTotal: () => number
clearCart: () => void
}
export const useCartStore = create((set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
}
}
return { items: [...state.items, { ...item, quantity: 1 }] }
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
getTotal: () => {
const state = get()
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
clearCart: () => set({ items: [] }),
})) // components/CartButton.tsx
'use client'
import { useCartStore } from '@/stores/useCartStore'
export function CartButton() {
const items = useCartStore((state) => state.items)
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
return (
)
}Pattern 3: React Query for server state with client cache
// hooks/useProducts.ts
'use client'
import { useQuery } from '@tanstack/react-query'
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/products')
if (!res.ok) throw new Error('Failed to fetch products')
return res.json()
},
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
})
}Pattern 4: Server Actions for server state mutations
// app/products/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function addToCart(productId: string, quantity: number) {
// Server-side logic
await db.cart.upsert({
where: { productId },
update: { quantity: { increment: quantity } },
create: { productId, quantity },
})
revalidatePath('/cart')
return { success: true }
}// components/AddToCartButton.tsx
'use client'
import { addToCart } from '@/app/products/actions'
import { useTransition } from 'react'
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition()
return (
)
}Lessons Learned
Server state and client state are different things. Data fetched from APIs is server state. UI state like modals, forms, and selections is client state. Treat them differently.
Minimize the client-side surface area. Only mark components as
"use client"when they actually need interactivity. Keep the server rendering tree as large as possible.Use Server Actions for mutations. Instead of client-side API calls, use Server Actions to mutate data. They run on the server and can revalidate cached data automatically.
Zustand works fine in
"use client"components. There is no performance penalty for using Zustand in Client Components. The store lives in the browser and does not affect server rendering.Do not try to share state between Server and Client Components. Pass data from Server to Client via props. For bidirectional communication, use Server Actions or API routes.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.