6月5日 22:01

Next.js 状态管理该用哪个方案?Server Component 到 Zustand 怎么选

Next.js App Router 引入了 Server Components 后,状态管理的思路和纯 SPA 完全不同。很多数据根本不需要客户端状态——服务端直接获取、渲染、返回 HTML,客户端零 JS 开销。只有真正需要交互的数据才用客户端状态管理。

先问:这个状态真的需要客户端管理吗?

场景方案需要 JS 吗
页面初始数据Server Component 直接 fetch不需要
用户个人信息Server Component + cookies不需要
表单输入值useState需要
弹窗开关useState需要
跨页面共享的购物车URL 参数 / Cookie / 外部状态库看场景

原则:能用 Server Component 解决的不用客户端状态。每多一个客户端状态,就多一份 JS 发送到浏览器。

第一选择:URL 状态

搜索关键词、分页页码、筛选条件——这些状态天然属于 URL:

typescript
// app/products/page.tsx export default async function ProductsPage({ searchParams, }: { searchParams: { q?: string; page?: string } }) { const products = await searchProducts({ query: searchParams.q, page: Number(searchParams.page) || 1, }) return <ProductList products={products} /> }

URL 状态的优势:

  • 可分享:用户复制链接给别人,状态不丢
  • 浏览器后退/前进天然支持
  • SEO 友好:搜索引擎能抓到分页和筛选状态
  • 不需要 JS:Server Component 直接读取 searchParams

Client Component 中更新 URL 状态

typescript
'use client' import { useSearchParams, useRouter } from 'next/navigation' function SearchBar() { const searchParams = useSearchParams() const router = useRouter() function handleSearch(query: string) { const params = new URLSearchParams(searchParams) params.set('q', query) params.delete('page') // 搜索时重置分页 router.push(`/products?${params.toString()}`) } return <input defaultValue={searchParams.get('q') || ''} onChange={(e) => handleSearch(e.target.value)} /> }

第二选择:Server Component 传递 props

父组件获取数据,通过 props 传给子组件——最简单的状态传递方式:

typescript
// app/dashboard/page.tsx export default async function DashboardPage() { const [stats, recentOrders] = await Promise.all([ fetchStats(), fetchRecentOrders(), ]) return ( <div> <StatsCard stats={stats} /> <RecentOrders orders={recentOrders} /> </div> ) }

Promise.all 并行获取,不需要任何状态库。子组件拿到的是渲染好的数据,不需要 loading 状态。

第三选择:React Context

跨组件共享数据,但不需要跨页面共享:

typescript
// providers/theme-provider.tsx 'use client' import { createContext, useContext, useState } from 'react' type Theme = 'light' | 'dark' const ThemeContext = createContext<{ theme: Theme toggleTheme: () => void }>({ theme: 'light', toggleTheme: () => {} }) export function ThemeProvider({ children }: { children: React.ReactNode }) { const [theme, setTheme] = useState<Theme>('light') return ( <ThemeContext.Provider value={{ theme, toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'), }}> {children} </ThemeContext.Provider> ) } export function useTheme() { return useContext(ThemeContext) }

在根 layout 中注册:

typescript
// app/layout.tsx import { ThemeProvider } from '@/providers/theme-provider' export default function RootLayout({ children }) { return ( <html> <body> <ThemeProvider>{children}</ThemeProvider> </body> </html> ) }

注意:Context Provider 必须是 Client Component,包在 Server Component 的 layout 里使用。这会导致 Provider 下面的所有子组件在客户端渲染——所以只放必要的 Provider,不要把整个应用包在一个巨大的 Provider 树里。

第四选择:轻量外部状态库

当 Context 不够用时(跨页面、需要 devtools、需要中间件),用 Zustand——最小最轻的 React 状态库:

bash
npm install zustand
typescript
// store/cart-store.ts import { create } from 'zustand' interface CartItem { id: string name: string price: number quantity: number } interface CartStore { items: CartItem[] addItem: (item: Omit<CartItem, 'quantity'>) => void removeItem: (id: string) => void clearCart: () => void total: () => number } export const useCartStore = create<CartStore>((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), })), clearCart: () => set({ items: [] }), total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0), }))
typescript
// components/cart-button.tsx 'use client' import { useCartStore } from '@/store/cart-store' export function CartButton() { const items = useCartStore((s) => s.items) const total = useCartStore((s) => s.total) return ( <button>购物车 ({items.length}) ¥{total()}</button> ) }

Zustand 的优势:不需要 Provider 包裹,任何 Client Component 里直接 useCartStore()

Zustand 持久化到 localStorage

typescript
import { create } from 'zustand' import { persist } from 'zustand/middleware' export const useCartStore = create( persist<CartStore>( (set, get) => ({ // ... store 定义 }), { name: 'cart-storage', // localStorage key } ) )

刷新页面后购物车数据不丢失。但注意 SSR hydration 不匹配的问题——首次渲染用空状态,hydration 后从 localStorage 读取:

typescript
// 解决 hydration 不匹配 function CartButton() { const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) if (!mounted) return <button>购物车</button> const count = useCartStore((s) => s.items.length) return <button>购物车 ({count})</button> }

Server Actions 替代表单状态

传统方式:表单输入 → 客户端状态 → 提交 → API 调用。Server Actions 简化了这整个流程:

typescript
// app/products/actions.ts 'use server' import { revalidateTag } from 'next/cache' export async function createProduct(formData: FormData) { const name = formData.get('name') as string const price = Number(formData.get('price')) await db.product.create({ data: { name, price } }) revalidateTag('products') }
typescript
// app/products/page.tsx import { createProduct } from './actions' export function AddProductForm() { return ( <form action={createProduct}> <input name="name" required /> <input name="price" type="number" required /> <button type="submit">添加</button> </form> ) }

不需要 useState 管理表单值,不需要 fetch 调 API,提交后自动重新验证缓存。这是 App Router 推荐的表单处理方式。

状态管理选择流程

shell
需要跨页面共享吗? ├── 不需要 → 页面级用 Server Component,组件级用 useState └── 需要 → 能放 URL 吗? ├── 能 → URL 参数(searchParams) └── 不能 → 需要持久化吗? ├── 不需要 → React Context └── 需要 → Zustand + persist

各方案对比

方案跨页面持久化需要 JS复杂度
Server Component-不需要最低
URL 参数最少
useState需要
React Context是(同一 Provider 树)需要
Zustand可选需要
Redux可选需要

Next.js 项目中,80% 的状态可以用 Server Component + URL 参数解决。只有真正需要在客户端管理交互状态时,才用 Context 或 Zustand。不要上来就装 Redux——那是 SPA 时代的思维。

标签:Next.js