• 简体中文
  • 在 Next.js 中配置

    NOTE

    我们很快会根据 https://github.com/pmndrs/zustand/discussions/2740 中的讨论更新本指南。

    Next.js 是 React 生态中流行的服务端渲染框架,它在正确使用 Zustand 时会 带来一些独特的挑战。 请记住,Zustand store 是一个全局 变量(又称模块状态,module state),因此 Context 是可选的。 这些挑战包括:

    • 每个请求独立的 store: Next.js 服务端可以同时处理多个请求。这意味着 store 应当按请求创建,不应在请求之间共享。
    • 对 SSR 友好: Next.js 应用会被渲染两次,先在服务端渲染 一次,再在客户端渲染一次。客户端与服务端的输出不一致会导致 “水合错误(hydration error)”。为避免这种情况,store 必须在服务端初始化, 然后在客户端用相同的数据重新初始化。更多内容请阅读 SSR 与水合 指南。
    • 对 SPA 路由友好: Next.js 支持客户端路由的混合模式,这意味着 要重置某个 store,我们需要在组件层面使用 Context 来初始化它。
    • 对服务端缓存友好: 较新版本的 Next.js(特别是使用 App Router 架构的应用)支持激进的服务端缓存。由于我们的 store 是模块状态, 它与这种缓存完全兼容。

    对于如何正确使用 Zustand,我们有下面这些通用建议:

    • 不要使用全局 store —— 因为 store 不应在请求之间共享,所以不应把它定义 为全局变量。相反,store 应当按请求创建。
    • React Server Components 不应读取或写入 store —— RSC 不能使用 Hook 或上下文。它们 本来就不该是有状态的。让 RSC 读取全局 store 或向其写入值,违反了 Next.js 的架构。

    按请求创建 store

    下面我们来编写 store 工厂函数,它会为每个 请求创建一个新的 store。

    // tsconfig.json
    {
      "compilerOptions": {
        "lib": ["dom", "dom.iterable", "esnext"],
        "allowJs": true,
        "skipLibCheck": true,
        "strict": true,
        "noEmit": true,
        "esModuleInterop": true,
        "module": "esnext",
        "moduleResolution": "bundler",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "jsx": "preserve",
        "incremental": true,
        "plugins": [
          {
            "name": "next"
          }
        ],
        "paths": {
          "@/*": ["./src/*"]
        }
      },
      "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
      "exclude": ["node_modules"]
    }

    注意: 不要忘记删除 tsconfig.json 文件中的所有注释。

    初始化 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 })),
      }))
    }

    提供 store

    我们在组件中使用 createCounterStore,并通过上下文 Provider 共享它。

    // src/providers/counter-store-provider.tsx
    'use client'
    
    import { type ReactNode, createContext, useState, 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 [store] = useState(() => createCounterStore())
      return <CounterStoreContext.Provider value={store}>{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)
    }

    注意: 在这个示例中,我们通过检查引用的值来确保该组件在重新渲染时是安全的, 从而让 store 只被创建一次。这个组件在服务端每个请求 只会渲染一次,但在客户端可能会被多次重新渲染,比如 当组件树中该组件之上存在有状态的客户端组件,或者该组件 本身还包含其他会导致重新渲染的可变状态时。

    在不同架构中使用 store

    Next.js 应用有两种架构:Pages Router 和 App Router。 在两种架构中使用 Zustand 的方式应当相同,只是与各自架构相关的细节略有差异。

    Pages Router

    // src/components/pages/home-page.tsx
    import { useCounterStore } from '@/providers/counter-store-provider'
    
    export const HomePage = () => {
      const { count, incrementCount, decrementCount } = useCounterStore((state) => state)
    
      return (
        <div>
          Count: {count}
          <hr />
          <button type="button" onClick={incrementCount}>
            Increment Count
          </button>
          <button type="button" onClick={decrementCount}>
            Decrement Count
          </button>
        </div>
      )
    }
    // src/_app.tsx
    import type { AppProps } from 'next/app'
    
    import { CounterStoreProvider } from '@/providers/counter-store-provider'
    
    export default function App({ Component, pageProps }: AppProps) {
      return (
        <CounterStoreProvider>
          <Component {...pageProps} />
        </CounterStoreProvider>
      )
    }
    // src/pages/index.tsx
    import { HomePage } from '@/components/pages/home-page'
    
    export default function Home() {
      return <HomePage />
    }

    注意: 按路由创建 store 需要在页面(路由)组件层面创建并共享 store。如果你不需要为每个路由创建 store, 请尽量不要这样做。

    // src/pages/index.tsx
    import { CounterStoreProvider } from '@/providers/counter-store-provider'
    import { HomePage } from '@/components/pages/home-page'
    
    export default function Home() {
      return (
        <CounterStoreProvider>
          <HomePage />
        </CounterStoreProvider>
      )
    }

    App Router

    // src/components/pages/home-page.tsx
    'use client'
    
    import { useCounterStore } from '@/providers/counter-store-provider'
    
    export const HomePage = () => {
      const { count, incrementCount, decrementCount } = useCounterStore((state) => state)
    
      return (
        <div>
          Count: {count}
          <hr />
          <button type="button" onClick={incrementCount}>
            Increment Count
          </button>
          <button type="button" onClick={decrementCount}>
            Decrement Count
          </button>
        </div>
      )
    }
    // src/app/layout.tsx
    import type { Metadata } from 'next'
    import { Inter } from 'next/font/google'
    import './globals.css'
    
    import { CounterStoreProvider } from '@/providers/counter-store-provider'
    
    const inter = Inter({ subsets: ['latin'] })
    
    export const metadata: Metadata = {
      title: 'Create Next App',
      description: 'Generated by create next app',
    }
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode
    }>) {
      return (
        <html lang="en">
          <body className={inter.className}>
            <CounterStoreProvider>{children}</CounterStoreProvider>
          </body>
        </html>
      )
    }
    // src/app/page.tsx
    import { HomePage } from '@/components/pages/home-page'
    
    export default function Home() {
      return <HomePage />
    }

    注意: 按路由创建 store 需要在页面(路由)组件层面创建并共享 store。如果你不需要为每个路由创建 store, 请尽量不要这样做。

    // src/app/page.tsx
    import { CounterStoreProvider } from '@/providers/counter-store-provider'
    import { HomePage } from '@/components/pages/home-page'
    
    export default function Home() {
      return (
        <CounterStoreProvider>
          <HomePage />
        </CounterStoreProvider>
      )
    }