Skip to content

使用 Next.js 设置

¥Setup with Next.js

Next.js 是一种流行的 React 服务器端渲染框架,它为正确使用 Zustand 带来了一些独特的挑战。请记住,Zustand 存储是一个全局变量(又名模块状态),因此可以选择使用 Context。这些挑战包括:

¥Next.js is a popular server-side rendering framework for React that presents some unique challenges for using Zustand properly. Keep in mind that Zustand store is a global variable (AKA module state) making it optional to use a Context. These challenges include:

  • 每个请求存储:Next.js 服务器可以同时处理多个请求。这意味着存储应该根据请求创建,并且不应在请求之间共享。

    ¥Per-request store: A Next.js server can handle multiple requests simultaneously. This means that the store should be created per request and should not be shared across requests.

  • SSR 友好:Next.js 应用渲染两次,第一次在服务器上,第二次在客户端上。客户端和服务器上的输出不同将导致 "水合错误。" 存储必须在服务器上初始化,然后在客户端上使用相同的数据重新初始化,以避免这种情况。请在我们的 SSR 和 Hydration 指南中阅读更多相关信息。

    ¥SSR friendly: Next.js applications are rendered twice, first on the server and again on the client. Having different outputs on both the client and the server will result in "hydration errors." The store will have to be initialized on the server and then re-initialized on the client with the same data in order to avoid that. Please read more about that in our SSR and Hydration guide.

  • SPA 路由友好:Next.js 支持客户端路由的混合模型,这意味着为了重置存储,我们需要使用 Context 在组件级别对其进行初始化。

    ¥SPA routing friendly: Next.js supports a hybrid model for client side routing, which means that in order to reset a store, we need to initialize it at the component level using a Context.

  • 服务器缓存友好:Next.js 的最新版本(特别是使用 App Router 架构的应用)支持积极的服务器缓存。由于我们的存储是模块状态,因此它与此缓存完全兼容。

    ¥Server caching friendly: Recent versions of Next.js (specifically applications using the App Router architecture) support aggressive server caching. Due to our store being a module state, it is completely compatible with this caching.

我们对 Zustand 的适当使用有以下一般建议:

¥We have these general recommendations for the appropriate use of Zustand:

  • 没有全局存储 - 因为存储不应该在请求之间共享,所以它不应该定义为全局变量。相反,应该根据请求创建存储。

    ¥No global stores - Because the store should not be shared across requests, it should not be defined as a global variable. Instead, the store should be created per request.

  • React 服务器组件不应从存储中读取或写入 - RSC 不能使用钩子或上下文。它们不具有状态。让 RSC 从全局存储中读取或写入值违反了 Next.js 的架构。

    ¥React Server Components should not read from or write to the store - RSCs cannot use hooks or context. They aren't meant to be stateful. Having an RSC read from or write values to a global store violates the architecture of Next.js.

为每个请求创建一个存储

¥Creating a store per request

让我们编写我们的存储工厂函数,它将为每个请求创建一个新的存储。

¥Let's write our store factory function that will create a new store for each request.

json
// 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 文件中删除所有注释。

¥Note: do not forget to remove all comments from your tsconfig.json file.

ts
// 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 })),
  }))
}

提供存储

¥Providing the store

让我们在组件中使用 createCounterStore 并使用上下文提供程序共享它。

¥Let's use the createCounterStore in our component and share it using a context provider.

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

注意:在此示例中,我们通过检查引用的值来确保此组件是重新渲染安全的,以便仅创建一次存储。此组件在服务器上每个请求仅渲染一次,但如果树中此组件上方有状态客户端组件,或者此组件还包含导致重新渲染的其他可变状态,则可能会在客户端上重新渲染多次。

¥Note: In this example, we ensure that this component is re-render-safe by checking the value of the reference, so that the store is only created once. This component will only be rendered once per request on the server, but might be re-rendered multiple times on the client if there are stateful client components located above this component in the tree, or if this component also contains other mutable state that causes a re-render.

初始化存储

¥Initializing the store

ts
// 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 initCounterStore = (): CounterState => {
  return { count: new Date().getFullYear() }
}

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 })),
  }))
}
tsx
// src/providers/counter-store-provider.tsx
'use client'

import { type ReactNode, createContext, useRef, useContext } from 'react'
import { useStore } from 'zustand'

import {
  type CounterStore,
  createCounterStore,
  initCounterStore,
} 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(initCounterStore())
  }

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

使用具有不同架构的存储

¥Using the store with different architectures

Next.js 应用有两种架构:页面路由应用路由。两种架构上 Zustand 的用法应该相同,但每种架构略有不同。

¥There are two architectures for a Next.js application: the Pages Router and the App Router. The usage of Zustand on both architectures should be the same with slight differences related to each architecture.

页面路由

¥Pages Router

tsx
// src/components/pages/home-page.tsx
import { useCounterStore } from '@/providers/counter-store-provider.ts'

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>
  )
}
tsx
// src/_app.tsx
import type { AppProps } from 'next/app'

import { CounterStoreProvider } from '@/providers/counter-store-provider.tsx'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <CounterStoreProvider>
      <Component {...pageProps} />
    </CounterStoreProvider>
  )
}
tsx
// src/pages/index.tsx
import { HomePage } from '@/components/pages/home-page.tsx'

export default function Home() {
  return <HomePage />
}

注意:为每个路由创建一个存储需要在页面(路由)组件级别创建和共享存储。如果你不需要为每个路由创建一个存储,请尽量不要使用它。

¥Note: creating a store per route would require creating and sharing the store at page (route) component level. Try not to use this if you do not need to create a store per route.

tsx
// src/pages/index.tsx
import { CounterStoreProvider } from '@/providers/counter-store-provider.tsx'
import { HomePage } from '@/components/pages/home-page.tsx'

export default function Home() {
  return (
    <CounterStoreProvider>
      <HomePage />
    </CounterStoreProvider>
  )
}

应用路由

¥App Router

tsx
// 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>
  )
}
tsx
// 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>
  )
}
tsx
// src/app/page.tsx
import { HomePage } from '@/components/pages/home-page'

export default function Home() {
  return <HomePage />
}

注意:为每个路由创建一个存储需要在页面(路由)组件级别创建和共享存储。如果你不需要为每个路由创建一个存储,请尽量不要使用它。

¥Note: creating a store per route would require creating and sharing the store at page (route) component level. Try not to use this if you do not need to create a store per route.

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

Zustand v5.0 中文网 - 粤ICP备13048890号