在 Next.js App Router 中使用 Zustand
关于 Server Components、SSR 以及避免全局状态污染的完整指南。
挑战:全局状态 vs SSR
在单页应用 (SPA) 中,全局 store 变量没问题。但在 Next.js (SSR) 中,全局变量在服务器上的所有请求之间共享。这意味着如果不小心,一个用户的数据可能会意外泄漏给另一个用户。
警告:如果你计划用服务器数据初始化 store,请勿使用 `create()` 定义全局 store。
解决方案:Store Provider 模式
为了确保隔离,我们使用 React Context 为每个请求(或组件树)创建一个新的 store 实例。
1. 创建 Store 工厂
// src/stores/counter-store.ts
import { createStore } from 'zustand/vanilla'
export type CounterState = {
count: number
}
export type CounterActions = {
decrementCount: () => void
incrementCount: () => void
}
export type CounterStore = CounterState & CounterActions
export const defaultInitState: CounterState = {
count: 0,
}
export const createCounterStore = (initState: CounterState = defaultInitState) => {
return createStore<CounterStore>()((set) => ({
...initState,
decrementCount: () => set((state) => ({ count: state.count - 1 })),
incrementCount: () => set((state) => ({ count: state.count + 1 })),
}))
}2. 创建 Provider
// src/providers/counter-store-provider.tsx
'use client'
import { type ReactNode, createContext, useRef, useContext } from 'react'
import { useStore } from 'zustand'
import { type CounterStore, createCounterStore } from '@/stores/counter-store'
export type CounterStoreApi = ReturnType<typeof createCounterStore>
export const CounterStoreContext = createContext<CounterStoreApi | undefined>(
undefined,
)
export interface CounterStoreProviderProps {
children: ReactNode
}
export const CounterStoreProvider = ({
children,
}: CounterStoreProviderProps) => {
const storeRef = useRef<CounterStoreApi>(null)
if (!storeRef.current) {
storeRef.current = createCounterStore()
}
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
)
}
export const useCounterStore = <T,>(
selector: (store: CounterStore) => T,
): T => {
const counterStoreContext = useContext(CounterStoreContext)
if (!counterStoreContext) {
throw new Error(`useCounterStore must be used within CounterStoreProvider`)
}
return useStore(counterStoreContext, selector)
}3. 在 Layout 或 Page 中使用
// app/layout.tsx
import { CounterStoreProvider } from '@/providers/counter-store-provider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<CounterStoreProvider>
{children}
</CounterStoreProvider>
</body>
</html>
)
}