Merge branch 'main' into v5

This commit is contained in:
daishi 2024-02-17 11:11:28 +09:00
commit 7868b8c005
10 changed files with 1318 additions and 694 deletions

View File

@ -31,8 +31,8 @@ jobs:
- 18.0.0
- 18.1.0
- 18.2.0
- 18.3.0-canary-feed8f3f9-20240118
- 0.0.0-experimental-feed8f3f9-20240118
- 18.3.0-canary-a9cc32511-20240215
- 0.0.0-experimental-a9cc32511-20240215
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4

399
docs/guides/nextjs.md Normal file
View File

@ -0,0 +1,399 @@
---
title: Setup with Next.js
nav: 21
---
[Next.js](https://nextjs.org) 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:
- **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 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](./ssr-and-hydration) guide.
- **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 intialize it at the component level using a
`Context`.
- **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.
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 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"]
}
```
> **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
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 { type StoreApi, useStore } from 'zustand'
import { type CounterStore, createCounterStore } from '@/stores/counter-store'
export const CounterStoreContext = createContext<StoreApi<CounterStore> | null>(
null,
)
export interface CounterStoreProviderProps {
children: ReactNode
}
export const CounterStoreProvider = ({
children,
}: CounterStoreProviderProps) => {
const storeRef = useRef<StoreApi<CounterStore>>()
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 use 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 { type StoreApi, useStore } from 'zustand'
import {
type CounterStore,
createCounterStore,
initCounterStore,
} from '@/stores/counter-store'
export const CounterStoreContext = createContext<StoreApi<CounterStore> | null>(
null,
)
export interface CounterStoreProviderProps {
children: ReactNode
}
export const CounterStoreProvider = ({
children,
}: CounterStoreProviderProps) => {
const storeRef = useRef<StoreApi<CounterStore>>()
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 use within CounterStoreProvider`)
}
return useStore(counterStoreContext, selector)
}
```
### Using the store with different architectures
There are two architectures for a Next.js application: the
[Pages Router](https://nextjs.org/docs/pages/building-your-application/routing) and the
[App Router](https://nextjs.org/docs/app/building-your-application/routing). 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={() => void incrementCount()}>
Increment Count
</button>
<button type="button" onClick={() => void 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={() => void incrementCount()}>
Increment Count
</button>
<button type="button" onClick={() => void 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>
)
}
```

View File

@ -0,0 +1,198 @@
---
title: SSR and Hydration
nav: 20
---
## Server-side Rendering (SSR)
Server-side Rendering (SSR) is a technique that helps us render our components into
HTML strings on the server, send them directly to the browser, and finally "hydrate" the
static markup into a fully interactive app on the client.
### React
Let's say we want to render a stateless app using React. In order to do that, we need
to use `express`, `react` and `react-dom/server`. We don't need `react-dom/client`
since it's a stateless app.
Let's dive into that:
- `express` helps us build a web app that we can run using Node,
- `react` helps us build the UI components that we use in our app,
- `react-dom/server` helps us render our components on a server.
```json
// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "esnext"
},
"include": ["**/*"]
}
```
> **Note:** do not forget to remove all comments from your `tsconfig.json` file.
```tsx
// app.tsx
export const App = () => {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Static Server-side-rendered App</title>
</head>
<body>
<div>Hello World!</div>
</body>
</html>
)
}
```
```tsx
// server.tsx
import express from 'express'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { App } from './app.tsx'
const port = Number.parseInt(process.env.PORT || '3000', 10)
const app = express()
app.get('/', (_, res) => {
const { pipe } = ReactDOMServer.renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('content-type', 'text/html')
pipe(res)
},
})
})
app.listen(port, () => {
console.log(`Server is listening at ${port}`)
})
```
```sh
tsc --build
```
```sh
node server.js
```
## Hydration
Hydration turns the initial HTML snapshot from the server into a fully interactive app
that runs in the browser. The right way to "hydrate" a component is by using `hydrateRoot`.
### React
Let's say we want to render an statefull app using react. In order to do that we need to
use `express`, `react`, `react-dom/server` and `react-dom/client`.
Let's dive into that:
- `express` helps us build a web app that we can run using Node,
- `react` helps us build the UI components that we use in our app,
- `react-dom/server` helps us render our components on a server,
- `react-dom/client` helps us hydrate our components on a client.
> **Note:** Do not forget that even if we can render our components on a server, it is
> important to "hydrate" them on a client to make them interactive.
```json
// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "esnext"
},
"include": ["**/*"]
}
```
> **Note:** do not forget to remove all comments in your `tsconfig.json` file.
```tsx
// app.tsx
export const App = () => {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Static Server-side-rendered App</title>
</head>
<body>
<div>Hello World!</div>
</body>
</html>
)
}
```
```tsx
// main.tsx
import ReactDOMClient from 'react-dom/client'
import { App } from './app.tsx'
ReactDOMClient.hydrateRoot(<App />, document)
```
```tsx
// server.tsx
import express from 'express'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { App } from './app.tsx'
const port = Number.parseInt(process.env.PORT || '3000', 10)
const app = express()
app.use('/', (_, res) => {
const { pipe } = ReactDOMServer.renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('content-type', 'text/html')
pipe(res)
},
})
})
app.listen(port, () => {
console.log(`Server is listening at ${port}`)
})
```
```sh
tsc --build
```
```sh
node server.js
```
> **Warning:** The React tree you pass to `hydrateRoot` needs to produce the same output as it did on the server.
> The most common causes leading to hydration errors include:
>
> - Extra whitespace (like newlines) around the React-generated HTML inside the root node.
> - Using checks like typeof window !== 'undefined' in your rendering logic.
> - Using browser-only APIs like `window.matchMedia` in your rendering logic.
> - Rendering different data on the server and the client.
>
> React recovers from some hydration errors, but you must fix them like other bugs. In the best case, theyll lead to a slowdown; in the worst case, event handlers can get attached to the wrong elements.
You can read more about the caveats and pitfalls here: [hydrateRoot](https://react.dev/reference/react-dom/client/hydrateRoot)

View File

@ -79,7 +79,7 @@ export const storeResetFns = new Set<() => void>()
const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getState()
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
@ -98,7 +98,7 @@ export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getState()
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
@ -168,7 +168,7 @@ export const storeResetFns = new Set<() => void>()
const createUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getState()
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
@ -187,7 +187,7 @@ export const create = (<T>(stateCreator: zustand.StateCreator<T>) => {
const createStoreUncurried = <T>(stateCreator: zustand.StateCreator<T>) => {
const store = actualCreateStore(stateCreator)
const initialState = store.getState()
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})

View File

@ -259,7 +259,11 @@ const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
set(...a)
console.log(...(name ? [`${name}:`] : []), get())
}
store.setState = loggedSet
const setState = store.setState
store.setState = (...a) => {
setState(...a)
console.log(...(name ? [`${name}:`] : []), store.getState())
}
return f(loggedSet, get, store)
}

View File

@ -108,29 +108,29 @@
},
"homepage": "https://github.com/pmndrs/zustand",
"devDependencies": {
"@babel/core": "^7.23.7",
"@babel/core": "^7.23.9",
"@babel/plugin-external-helpers": "^7.23.3",
"@babel/plugin-transform-react-jsx": "^7.23.4",
"@babel/plugin-transform-runtime": "^7.23.7",
"@babel/plugin-transform-runtime": "^7.23.9",
"@babel/plugin-transform-typescript": "^7.23.6",
"@babel/preset-env": "^7.23.8",
"@babel/preset-env": "^7.23.9",
"@redux-devtools/extension": "^3.3.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-typescript": "^11.1.6",
"@testing-library/react": "^14.1.2",
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@testing-library/react": "^14.2.1",
"@types/node": "^20.11.19",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/use-sync-external-store": "^0.0.6",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"@vitest/coverage-v8": "0.33.0",
"@vitest/ui": "0.33.0",
"concurrently": "^8.2.2",
"esbuild": "^0.19.11",
"esbuild": "^0.20.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-alias": "^1.1.2",
@ -138,16 +138,16 @@
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-vitest": "^0.3.20",
"eslint-plugin-vitest": "^0.3.22",
"immer": "^10.0.3",
"jsdom": "^23.2.0",
"jsdom": "^24.0.0",
"json": "^11.0.0",
"prettier": "^3.2.4",
"prettier": "^3.2.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"redux": "^5.0.1",
"rollup": "^4.9.5",
"rollup-plugin-esbuild": "^6.1.0",
"rollup": "^4.12.0",
"rollup-plugin-esbuild": "^6.1.1",
"shx": "^0.3.4",
"typescript": "^5.3.3",
"use-sync-external-store": "^1.2.0",

View File

@ -284,31 +284,6 @@ clearForest()
[Alternatively, there are some other solutions.](./docs/guides/updating-state.md#with-immer)
## Middleware
You can functionally compose your store any way you like.
```jsx
// Log every time state is changed
const log = (config) => (set, get, api) =>
config(
(...args) => {
console.log(' applying', args)
set(...args)
console.log(' new state', get())
},
get,
api,
)
const useBeeStore = create(
log((set) => ({
bees: false,
setBees: (input) => set({ bees: input }),
})),
)
```
## Persist middleware
You can persist your store's data using any kind of storage.

View File

@ -6,8 +6,8 @@ import type {
export interface StateStorage {
getItem: (name: string) => string | null | Promise<string | null>
setItem: (name: string, value: string) => void | Promise<void>
removeItem: (name: string) => void | Promise<void>
setItem: (name: string, value: string) => unknown | Promise<unknown>
removeItem: (name: string) => unknown | Promise<unknown>
}
export type StorageValue<S> = {
@ -19,8 +19,8 @@ export interface PersistStorage<S> {
getItem: (
name: string,
) => StorageValue<S> | null | Promise<StorageValue<S> | null>
setItem: (name: string, value: StorageValue<S>) => void | Promise<void>
removeItem: (name: string) => void | Promise<void>
setItem: (name: string, value: StorageValue<S>) => unknown | Promise<unknown>
removeItem: (name: string) => unknown | Promise<unknown>
}
type JsonStorageOptions = {
@ -200,7 +200,7 @@ const persistImpl: PersistImpl = (config, baseOptions) => (set, get, api) => {
)
}
const setItem = (): void | Promise<void> => {
const setItem = () => {
const state = options.partialize({ ...get() })
return (storage as PersistStorage<S>).setItem(options.name, {
state,

View File

@ -14,9 +14,9 @@
"baseUrl": ".",
"paths": {
"zustand": ["./src/index.ts"],
"zustand/*": ["./src/*.ts"],
},
"zustand/*": ["./src/*.ts"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"],
"exclude": ["node_modules", "dist"]
}

1326
yarn.lock

File diff suppressed because it is too large Load Diff