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>
)
}