deep-dive2025-01-20·13 min·221/348

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.