Zustand
Introduction
zustand เป็น state management สำหรับ React ที่เล็ก และรวดเร็ว. Zustand มาพร้อม API ที่ใช้งานง่าย โดยอิงจาก hooks มันไม่ได้ซับซ้อนหรือบังคับให้ใช้รูปแบบที่ตายตัว แต่ก็มีรูปแบบเพียงพอที่จะทำให้ชัดเจน
มันทรงพลังมาก เราใช้เวลามากมายเพื่อแก้ปัญหาทั่วไป เช่น ปัญหา zombie child, การจัดการ React concurrency และการสูญเสีย context ระหว่างการใช้ renderer ที่หลากหลาย, zudtand อาจจะเป็น state manager หนึ่งเดียวในโลก React ที่จัดการเรื่องเหล่านี้ได้อย่างถูกต้อง
Installation
การใช้ zudtand เราสามารถใช้ NPM ในการติดตั้งได้
# NPM
npm install zustand
# Or, use any package manager of your choice.
First create a store
store ของเรา จะสามารถใช้งานแบบ Hook: เราสามารถใส่ primitives, objects, functions. เข้าไปใน set ได้ และมันจะทำหน้าที่จัดการให้เอง
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}))
Updating state
Flat updates
ในการ update state โดยการใช้ zudtand มันง่ายมาก, เราจะเรียใช้ set function และใส่ state ตัวใหม่ลงไปและมันจะไปรวมกับ state ตัวเก่าใน Store
import { create } from 'zustand'
type State = {
firstName: string
lastName: string
}
type Action = {
updateFirstName: (firstName: State['firstName']) => void
updateLastName: (lastName: State['lastName']) => void
}
// Create your store, which includes both state and (optionally) actions
const usePersonStore = create<State & Action>((set) => ({
firstName: '',
lastName: '',
updateFirstName: (firstName) => set(() => ({ firstName: firstName })),
updateLastName: (lastName) => set(() => ({ lastName: lastName })),
}))
// In consuming app
function App() {
// "select" the needed state and actions, in this case, the firstName value
// and the action updateFirstName
const firstName = usePersonStore((state) => state.firstName)
const updateFirstName = usePersonStore((state) => state.updateFirstName)
return (
<main>
<label>
First name
<input
// Update the "firstName" state
onChange={(e) => updateFirstName(e.currentTarget.value)}
value={firstName}
/>
</label>
<p>
Hello, <strong>{firstName}!</strong>
</p>
</main>
)
}
Deeply nested object
ถ้าเรามี nested Object แบบนี้
type State = {
deep: {
nested: {
obj: { count: number }
}
}
}
เราต้องทำการบางอย่าง เพื่อทำให้มั่นใจว่า มันถูกเปลี่ยนแปลงค่าจริง ๆ
Normal approach
เหมือนกับ React หรือ Redux วิธีธรรมดาคือการใช้ ... ในการเปลี่ยนแปลงค่า state
normalInc: () =>
set((state) => ({
deep: {
...state.deep,
nested: {
...state.deep.nested,
obj: {
...state.deep.nested.obj,
count: state.deep.nested.obj.count + 1
}
}
}
})),
นี่เป็นวิธีที่ยาวมาก เรามาดูวิธีอื่น ๆ ที่ง่ายกว่านี้กันเถอะ
With Immer
หลาย ๆ คนใช้ Immer ในการ update ค่าตัวแปรภายใน nested Object เราสามารถใช้มันใน React, Redux และ แน่นอน เราก็สามารถใช้มันใน zudtand ได้เหมือนกัน
เรามาดูตัวอย่างกันดีกว่า
immerInc: () =>
set(produce((state: State) => { ++state.deep.nested.obj.count })),
With optics-ts
อีกทางเลือก optics-ts:
opticsInc: () =>
set(O.modify(O.optic<State>().path("deep.nested.obj.count"))((c) => c + 1)),
With Ramda
เราสามารถใช้ Ramda ได้เหมือนกัน
ramdaInc: () =>
set(R.modifyPath(["deep", "nested", "obj", "count"], (c) => c + 1)),
CodeSandbox Demo
เราสามารถดูตัวอย่างการใช้งานจริงได้จาก
https://codesandbox.io/s/zustand-normal-immer-optics-ramda-updating-ynn3o?file=/src/App.tsx
Immutable state and merging
เหมือนกับ React's useState เราสามารถ update state โดยไม่ทับค่าตัวเก่า (เอาค่าตัวเก่ามา update ให้เป็นค่าใหม่)
ตัวอย่าง
import { create } from 'zustand'
const useCountStore = create((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
set function จะใช้สำหรับ update ค่าใน store เพราะว่า state สามารถเปลี่ยนแปลงค่าได้ และควรเขียนแบบนี้
set((state) => ({ ...state, count: state.count + 1 }))
อย่างไรก็ตาม นี่เป็นรูปแบบธรรมดา แต่ว่า set จะ merge ข้อมูลให้เป็นค่าใหม่ ดังนั้น เราไม่จำเป็นต้องใส่ ... ก็ได้
set((state) => ({ count: state.count + 1 }))
Nested objects
set function จะสามารถเปลี่ยนค่าได้จากแค่ชั้นเดียวเท่านั้น หมายความว่า ถ้าเราต้องเปลี่ยนข้อมูลของ nested Object เราต้องใช้ ...
import { create } from 'zustand'
const useCountStore = create((set) => ({
nested: { count: 0 },
inc: () =>
set((state) => ({
nested: { ...state.nested, count: state.nested.count + 1 },
})),
}))
ถ้ามีซ้อนกันมาก ๆ มันอาจจะซับซ้อนมากเกินไป เราสามารถใช้ตัวช่วยพวกนี้ได้ Updating nested state object values.
Replace flag
ถ้าเราต้องการที่จะทับค่าเท่าลงไปเลย เราสามารถกำหนดให้ replace boolean ไปที่ set ได้
set((state) => newState, true)
Flux inspired practice
ถึงแม้ Zudtand จะเป็น library ที่ไม่ได้ดังมาก แต่พวกเราแนะนำให้ใช้ pettern ในการใช้งาน zudtand ตามนี้. เราได้เรียนรู้การทำงานเป็น pettern แบบนี้จาก Flux และ Redux ดังนั้น ถ้าเราเคยใช้ library ตัวอื่น คุณจะรู้สึกเหมือนกำลังใช้ตัวเดิมอยู่เลย
Recommended patterns
Single store
ถ้า Application ของคุณมี global state เราควรจะใช้ Zustand store ตัวเดียว
แต่ถ้าคุณทำ Application ที่มีขนาดใหญ่มาก Zudtand ก็รองรับการทำ store หลายตัวเหมือนกัน splitting the store into slices.
Use set / setState to update the store
ควรจะใช้ set (หรือ setState) ในการ update store เสมอ เพื่อที่จะทำให้มั่นใจว่าการใช้งานมีประสิทธิภาพสูงสุด
Colocate store actions
ใน Zustand, state สามารถ Update ได้โดยไม่ต้องใช้ dispatched actions และ reducers แบบที่พบใน library Flux และ อื่น ๆ, store actions สามารถเพิ่มเข้าไปใน store ได้โดยตรงตามตัวอย่างด้านล่าง
const useBoundStore = create((set) => ({
storeSliceA: ...,
storeSliceB: ...,
storeSliceC: ...,
updateX: () => set(...),
updateY: () => set(...),
}))
Redux-like patterns
ถ้าคุณไม่ชินกับการไม่ใช้ Redux reducers เราสามารถใช้ dispatch function ใน root level ของ store ได้
const types = { increase: 'INCREASE', decrease: 'DECREASE' }
const reducer = (state, { type, by = 1 }) => {
switch (type) {
case types.increase:
return { grumpiness: state.grumpiness + by }
case types.decrease:
return { grumpiness: state.grumpiness - by }
}
}
const useGrumpyStore = create((set) => ({
grumpiness: 0,
dispatch: (args) => set((state) => reducer(state, args)),
}))
const dispatch = useGrumpyStore((state) => state.dispatch)
dispatch({ type: types.increase, by: 2 })
เราสามารถใช้ redux-middleware ของ Zudtand ได้ มันจะเชื่อม main reducer ของคุณ, ตั้งค่า initial state และ เพิ่ม dispatch function ไปที่ state โดยตัวมันเองได้
import { redux } from 'zustand/middleware'
const useReduxStore = create(redux(reducer, initialState))
Auto Generating Selectors
เราแนะนำให้ใช้ selectors เมื่อเราใช้ properties หรือ action จาก store, เราสามารถเข้าถึงค่าจาก Store ได้โดย
const bears = useBearStore((state) => state.bears)
อย่างไรก็ตาม การเขียนแบบนี้ค่อนข้างที่จะน่าเบื่อ ถ้าในกรณีนี้ เราสามารถใช้ auto-generate selector ของคุณได้
Create the following function: createSelectors
import { StoreApi, UseBoundStore } from 'zustand'
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never
const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
_store: S,
) => {
let store = _store as WithSelectors<typeof _store>
store.use = {}
for (let k of Object.keys(store.getState())) {
;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
}
return store
}
ถ้าเรามี store แบบนี้
interface BearState {
bears: number
increase: (by: number) => void
increment: () => void
}
const useBearStoreBase = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
increment: () => set((state) => ({ bears: state.bears + 1 })),
}))
รวม function นั้น เข้ากับ store ของเรา
const useBearStore = createSelectors(useBearStoreBase)
ตอนนี้ selectors จะ generated โดยอัตโนมัติ และ สามารถเข้าถึงมันโดยตรงได้
// get the property
const bears = useBearStore.use.bears()
// get the action
const increment = useBearStore.use.increment()
Vanilla Store
ถ้าคุณใช้ vanilla store เราจะใช้ createSelectors function
import { StoreApi, useStore } from 'zustand'
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never
const createSelectors = <S extends StoreApi<object>>(_store: S) => {
const store = _store as WithSelectors<typeof _store>
store.use = {}
for (const k of Object.keys(store.getState())) {
;(store.use as any)[k] = () =>
useStore(_store, (s) => s[k as keyof typeof s])
}
return store
}
การใช้งานเหมือนกับ React store.
ถ้าคุณมี store แบบนี้
import { createStore } from 'zustand'
interface BearState {
bears: number
increase: (by: number) => void
increment: () => void
}
const store = createStore<BearState>((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
increment: () => set((state) => ({ bears: state.bears + 1 })),
}))
ใช้ function นี้ กับ store ของคุณ
const useBearStore = createSelectors(store)
ตอนนี้ เราก็สามารถเข้าถึงมันได้โดยตรง
// get the property
const bears = useBearStore.use.bears()
// get the action
const increment = useBearStore.use.increment()
Live Demo
สำหรับตัวอย่างของการใช้งาน เราสามารถดูได้จาก Code Sandbox.
Practice with no store actions
จากที่เราแนะนำไปข้างต้นว่า ให้ใช้ actions และ states ภายใน store
ตัวอย่างเช่น
export const useBoundStore = create((set) => ({
count: 0,
text: 'hello',
inc: () => set((state) => ({ count: state.count + 1 })),
setText: (text) => set({ text }),
}))
นี่จะสร้าง store ที่เก็บ data และ action ไว้ด้วยกัน
แต่อีกทางเลือกหนึ่ง เราสามารถสร้าง function ต่าง ๆ ไว้ภายนอกของ store ได้
export const useBoundStore = create(() => ({
count: 0,
text: 'hello',
}))
export const inc = () =>
useBoundStore.setState((state) => ({ count: state.count + 1 }))
export const setText = (text) => useBoundStore.setState({ text })
Calling actions outside a React event handler in pre React 18
เพราะว่า React handler setState เป็น synchronous ถ้าเราเรียกใช้นอก handler ของมัน การ update นั้น จะบังคับให้ React update component ดังนั้นมันมีโอกาสที่จะเกิด zombie-child effect. ในการแก้ไขสิ่งนี้. action ต้องถูกหุ้มใน unstable_batchedUpdates เช่น
import { unstable_batchedUpdates } from 'react-dom' // or 'react-native'
const useFishStore = create((set) => ({
fishes: 0,
increaseFishes: () => set((prev) => ({ fishes: prev.fishes + 1 })),
}))
const nonReactCallback = () => {
unstable_batchedUpdates(() => {
useFishStore.getState().increaseFishes()
})
}
Map and Set Usage
เราต้องหุ้ม Map และ Set ไว้ภายใน object. เมื่อเราต้องการที่จะ update เราสามารถใช้ setState กับมันได้
คุณสามารถดูโค้ดตัวอย่างได้จาก: https://codesandbox.io/s/late-https-bxz9qy
import { create } from 'zustand'
const useFooBar = create(() => ({ foo: new Map(), bar: new Set() }))
function doSomething() {
// doing something...
// If you want to update some React component that uses `useFooBar`, you have to call setState
// to let React know that an update happened.
// Following React's best practices, you should create a new Map/Set when updating them:
useFooBar.setState((prev) => ({
foo: new Map(prev.foo).set('newKey', 'newValue'),
bar: new Set(prev.bar).add('newKey'),
}))
}
Connect to state with URL
Connect State with URL Hash
ถ้าเราต้องการที่จะเชื่อม state ของ store เข้ากับ URL เราสามารถสร้าง hash storage ของเราเองได้
import { create } from 'zustand'
import { persist, StateStorage, createJSONStorage } from 'zustand/middleware'
const hashStorage: StateStorage = {
getItem: (key): string => {
const searchParams = new URLSearchParams(location.hash.slice(1))
const storedValue = searchParams.get(key) ?? ''
return JSON.parse(storedValue)
},
setItem: (key, newValue): void => {
const searchParams = new URLSearchParams(location.hash.slice(1))
searchParams.set(key, JSON.stringify(newValue))
location.hash = searchParams.toString()
},
removeItem: (key): void => {
const searchParams = new URLSearchParams(location.hash.slice(1))
searchParams.delete(key)
location.hash = searchParams.toString()
},
}
export const useBoundStore = create(
persist(
(set, get) => ({
fishes: 0,
addAFish: () => set({ fishes: get().fishes + 1 }),
}),
{
name: 'food-storage', // unique name
storage: createJSONStorage(() => hashStorage),
},
),
)
CodeSandbox Demo
https://codesandbox.io/s/zustand-state-with-url-hash-demo-f29b88?file=/src/store/index.ts
Persist and Connect State with URL Parameters (Example: URL Query Parameters)
ในบางกรณี เราต้องการที่จะเชื่อม state เข้ากับ URL. ในตัวอย่างนี้ เราจะใช้ URL query parameters พร้อมกับการซิงค์ข้อมูลกับกับตัวเก็บข้อมูลตัวอื่น ๆ เช่น localstorage
หากคุณต้องการให้ URL parameter ถูกเติมข้อมูลเสมอ สามารถลบเงื่อนไขการตรวจสอบใน getUrlSearch() ออกได้
การใช้งานในตัวอย่างด้านล่างนี้จะอัปเดต URL โดยตรงโดยไม่ต้อง refresh หน้าเว็บ เมื่อ state ที่เกี่ยวข้องเปลี่ยนแปลง
import { create } from 'zustand'
import { persist, StateStorage, createJSONStorage } from 'zustand/middleware'
const getUrlSearch = () => {
return window.location.search.slice(1)
}
const persistentStorage: StateStorage = {
getItem: (key): string => {
// Check URL first
if (getUrlSearch()) {
const searchParams = new URLSearchParams(getUrlSearch())
const storedValue = searchParams.get(key)
return JSON.parse(storedValue as string)
} else {
// Otherwise, we should load from localstorage or alternative storage
return JSON.parse(localStorage.getItem(key) as string)
}
},
setItem: (key, newValue): void => {
// Check if query params exist at all, can remove check if always want to set URL
if (getUrlSearch()) {
const searchParams = new URLSearchParams(getUrlSearch())
searchParams.set(key, JSON.stringify(newValue))
window.history.replaceState(null, '', `?${searchParams.toString()}`)
}
localStorage.setItem(key, JSON.stringify(newValue))
},
removeItem: (key): void => {
const searchParams = new URLSearchParams(getUrlSearch())
searchParams.delete(key)
window.location.search = searchParams.toString()
},
}
type LocalAndUrlStore = {
typesOfFish: string[]
addTypeOfFish: (fishType: string) => void
numberOfBears: number
setNumberOfBears: (newNumber: number) => void
}
const storageOptions = {
name: 'fishAndBearsStore',
storage: createJSONStorage<LocalAndUrlStore>(() => persistentStorage),
}
const useLocalAndUrlStore = create(
persist<LocalAndUrlStore>(
(set) => ({
typesOfFish: [],
addTypeOfFish: (fishType) =>
set((state) => ({ typesOfFish: [...state.typesOfFish, fishType] })),
numberOfBears: 0,
setNumberOfBears: (numberOfBears) => set(() => ({ numberOfBears })),
}),
storageOptions,
),
)
export default useLocalAndUrlStore
เมื่อเรา generate URL จาก component เราสามารถเรียกใช้ buildShareableUrl ได้
const buildURLSuffix = (params, version = 0) => {
const searchParams = new URLSearchParams()
const zustandStoreParams = {
state: {
typesOfFish: params.typesOfFish,
numberOfBears: params.numberOfBears,
},
version: version, // version is here because that is included with how Zustand sets the state
}
// The URL param key should match the name of the store, as specified as in storageOptions above
searchParams.set('fishAndBearsStore', JSON.stringify(zustandStoreParams))
return searchParams.toString()
}
export const buildShareableUrl = (params, version) => {
return `${window.location.origin}?${buildURLSuffix(params, version)}`
}
generated URL จะมีหน้าตาแบบนี้ (ไม่มีการเข้ารหัส เพื่อสามารถทำให้อ่านได้):
https://localhost/search?fishAndBearsStore={"state":{"typesOfFish":["tilapia","salmon"],"numberOfBears":15},"version":0}}
How to reset state
pattern ต่อไปนี้ จะสามารถ reset ค่าของ state ได้
import { create } from 'zustand'
// define types for state values and actions separately
type State = {
salmon: number
tuna: number
}
type Actions = {
addSalmon: (qty: number) => void
addTuna: (qty: number) => void
reset: () => void
}
// define the initial state
const initialState: State = {
salmon: 0,
tuna: 0,
}
// create store
const useSlice = create<State & Actions>()((set, get) => ({
...initialState,
addSalmon: (qty: number) => {
set({ salmon: get().salmon + qty })
},
addTuna: (qty: number) => {
set({ tuna: get().tuna + qty })
},
reset: () => {
set(initialState)
},
}))
Resetting stores หลาย ๆ ตัว ในครั้งเดียว
import type { StateCreator } from 'zustand'
import { create: actualCreate } from 'zustand'
const storeResetFns = new Set<() => void>()
const resetAllStores = () => {
storeResetFns.forEach((resetFn) => {
resetFn()
})
}
export const create = (<T>() => {
return (stateCreator: StateCreator<T>) => {
const store = actualCreate(stateCreator)
const initialState = store.getInitialState()
storeResetFns.add(() => {
store.setState(initialState, true)
})
return store
}
}) as typeof actualCreate
- Basic: https://codesandbox.io/s/zustand-how-to-reset-state-basic-demo-rrqyon
- Advanced: https://codesandbox.io/s/zustand-how-to-reset-state-advanced-demo-gtu0qe
- Immer: https://codesandbox.io/s/how-to-reset-state-advance-immer-demo-nyet3f
Initialize state with props
ในกรณีที่เราต้องการ dependency injection เช่นเมื่อ store ควรจะมีค่าเริ่มต้นจาก Props ของ Component วิธีที่เราแนะนำคือใช้ vanilla store กับ React.context.
Store creator with createStore
import { createStore } from 'zustand'
interface BearProps {
bears: number
}
interface BearState extends BearProps {
addBear: () => void
}
type BearStore = ReturnType<typeof createBearStore>
const createBearStore = (initProps?: Partial<BearProps>) => {
const DEFAULT_PROPS: BearProps = {
bears: 0,
}
return createStore<BearState>()((set) => ({
...DEFAULT_PROPS,
...initProps,
addBear: () => set((state) => ({ bears: ++state.bears })),
}))
}
Creating a context with React.createContext
import { createContext } from 'react'
export const BearContext = createContext<BearStore | null>(null)
Basic component usage
// Provider implementation
import { useRef } from 'react'
function App() {
const store = useRef(createBearStore()).current
return (
<BearContext.Provider value={store}>
<BasicConsumer />
</BearContext.Provider>
)
}
// Consumer component
import { useContext } from 'react'
import { useStore } from 'zustand'
function BasicConsumer() {
const store = useContext(BearContext)
if (!store) throw new Error('Missing BearContext.Provider in the tree')
const bears = useStore(store, (s) => s.bears)
const addBear = useStore(store, (s) => s.addBear)
return (
<>
<div>{bears} Bears.</div>
<button onClick={addBear}>Add bear</button>
</>
)
}
Common patterns
Wrapping the context provider
// Provider wrapper
import { useRef } from 'react'
type BearProviderProps = React.PropsWithChildren<BearProps>
function BearProvider({ children, ...props }: BearProviderProps) {
const storeRef = useRef<BearStore>()
if (!storeRef.current) {
storeRef.current = createBearStore(props)
}
return (
<BearContext.Provider value={storeRef.current}>
{children}
</BearContext.Provider>
)
}
Extracting context logic into a custom hook
// Mimic the hook returned by `create`
import { useContext } from 'react'
import { useStore } from 'zustand'
function useBearContext<T>(selector: (state: BearState) => T): T {
const store = useContext(BearContext)
if (!store) throw new Error('Missing BearContext.Provider in the tree')
return useStore(store, selector)
}
// Consumer usage of the custom hook
function CommonConsumer() {
const bears = useBearContext((s) => s.bears)
const addBear = useBearContext((s) => s.addBear)
return (
<>
<div>{bears} Bears.</div>
<button onClick={addBear}>Add bear</button>
</>a
)
}
Optionally allow using a custom equality function
// Allow custom equality function by using useStoreWithEqualityFn instead of useStore
import { useContext } from 'react'
import { useStoreWithEqualityFn } from 'zustand/traditional'
function useBearContext<T>(
selector: (state: BearState) => T,
equalityFn?: (left: T, right: T) => boolean,
): T {
const store = useContext(BearContext)
if (!store) throw new Error('Missing BearContext.Provider in the tree')
return useStoreWithEqualityFn(store, selector, equalityFn)
}
Complete example
// Provider wrapper & custom hook consumer
function App2() {
return (
<BearProvider bears={2}>
<HookConsumer />
</BearProvider>
)
}
Slices Pattern
Slicing the store into smaller stores
store ของเราสามารถมีขนาดใหญ่ขึ้น และ ใหญ่ขึ้น และ ยากขึ้นได้ ดังนั้นเราสามารถเพิ่ม features wfh
store ของเราสามารถแบ่งออกมาเป็น store เล็ก ๆ ได้ โดย Zudtand สามารถจัดการปัญหานี้ได้ง่ายมาก
store ตัวแรก
export const createFishSlice = (set) => ({
fishes: 0,
addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})
store ตัวที่สอง
export const createBearSlice = (set) => ({
bears: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})
ตอนนี้ เราสามารถรวม stores ทั้งสองเป็น one bounded store ได้
import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'
export const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))
Usage in a React component
import { useBoundStore } from './stores/useBoundStore'
function App() {
const bears = useBoundStore((state) => state.bears)
const fishes = useBoundStore((state) => state.fishes)
const addBear = useBoundStore((state) => state.addBear)
return (
<div>
<h2>Number of bears: {bears}</h2>
<h2>Number of fishes: {fishes}</h2>
<button onClick={() => addBear()}>Add a bear</button>
</div>
)
}
export default App
Updating multiple stores
เราสามารถ update หลาย ๆ store ในเวลาเดียวกันได้ ภายใน function เดียว
export const createBearFishSlice = (set, get) => ({
addBearAndFish: () => {
get().addBear()
get().addFish()
},
})
รวม store ทั้งหมด เข้าด้วยกัน
import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'
import { createBearFishSlice } from './createBearFishSlice'
export const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
...createBearFishSlice(...a),
}))
Adding middlewares
เพิ่ม middlewares เข้าไปที่ store รวม
เพิ่ม persist middleware ไปที่ useBoundStore
import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'
import { persist } from 'zustand/middleware'
export const useBoundStore = create(
persist(
(...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}),
{ name: 'bound-store' },
),
)
จำไว้ว่า เราควรที่จะเพิ่มเข้าไปที่ combined store ถ้าเราเพิ่มเข้าไปที่ store เดี่ยว มันอาจเกิดปัญหาที่ไม่ขาดคิดได้
Prevent rerenders with useShallow
เมื่อเราต้องการที่จะติดตาม computed state จาก store ทางที่เราแนะนำ คือการใช้ selector
computed selector จะทำให้เกิด rerender ถ้า output เปลี่ยนไปจาก Object.is.
ในกรณีนี้ เราต้องการจะใช้ useShallow เพื่อป้องกันการเกิดการ rerender ถ้า computed value มีค่าเหมือนกับตัวก่อนหน้า
Example
เรามี store ที่เกี่ยวกับหมีแต่ละตัว และอาหารของมัน และเราต้องการที่จะ render ชื่อของมัน
import { create } from 'zustand'
const useMeals = create(() => ({
papaBear: 'large porridge-pot',
mamaBear: 'middle-size porridge pot',
littleBear: 'A little, small, wee pot',
}))
export const BearNames = () => {
const names = useMeals((state) => Object.keys(state))
return <div>{names.join(', ')}</div>
}
แต่ว่า พ่อหมีต้องการ พิซซ่าแทน
useMeals.setState({
papaBear: 'a large pizza',
})
การเปลี่ยนแปลงนี้ จะทำให้เกิดการ rerender ถึงแม้ output ของ names ไม่ได้เกิดการเปลี่ยนแปลงตาม shallow equal (การเปรียบเทียบ Object ของ 2 ตัว ในระดับผิวเผิน)
เราสามารถแก้ไขได้โดยใช้ useShallow
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
const useMeals = create(() => ({
papaBear: 'large porridge-pot',
mamaBear: 'middle-size porridge pot',
littleBear: 'A little, small, wee pot',
}))
export const BearNames = () => {
const names = useMeals(useShallow((state) => Object.keys(state)))
return <div>{names.join(', ')}</div>
}
ตอนนี้ มันสามารถสั่งสามารถชิ้นใหม่ โดยไม่ทำให้เกิดการ rerender ขึ้นได้
SSR and Hydration
Server-side Rendering (SSR)
Server-side Rendering (SSR) เป็นเทคนิคที่ช่วยให้เราสามารถ render Component ของเรา ไปเป็น HTML strings จาก Server ได้ และส่งมันไปที่ Browser และ hydrate ตัว HTML String ไปเป็น HTML ได้
React
ลองบอกว่า เราต้องการจะ render stateless Application โดยใช้ React ในการทำแบบนั้น เราต้องใช้ express, react และ react-dom/server. เราไม่จำเป็นต้องใช้ react-dom/client เพราะว่า Application ของเราเป็น stateless
เรามาเจาะลึกกัน
expressช่วยให้เราสร้างเว็บโดยใช้ node js ได้reactช่วยให้เราสร้าง UI component ที่ใช้ใน Application ของเราได้react-dom/serverช่วยให้เรา render components เรา จาก server ได้
// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "esnext"
},
"include": ["**/*"]
}
// 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>
)
}
// 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}`)
})
tsc --buildnode server.js
node server.js
Hydration
Hydration เปลี่ยนจาก String HTML ที่ได้รับมาจาก Server เป็น HTML จริง ๆ ที่ใช้ใน Application ของเรา
ในการทำ hydrate, component จะใช้ hydrateRoot
React
ลองบอกว่า เราต้องการจะสร้าง stateful Application โดยใช้ React ในกรณีนั้น เราจะต้องใช้ express, react, react-dom/server และ react-dom/client.
เรามาเจาะลึกกัน
expressช่วยให้เราสร้างเว็บโดยใช้ node js ได้reactช่วยให้เราสร้าง UI component ที่ใช้ใน Application ของเราได้react-dom/serverช่วยให้เรา render components เรา จาก server ได้react-dom/clientช่วยให้เรา hydrate Component บน Client ได้
// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "esnext"
},
"include": ["**/*"]
}
// 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>
)
}
// main.tsx
import ReactDOMClient from 'react-dom/client'
import { App } from './app.tsx'
ReactDOMClient.hydrateRoot(document, <App />)
// 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}`)
})
tsc --build
node server.js
คำเตือน: React tree ที่คุณส่งให้กับ
hydrateRootจะต้องให้ผลลัพธ์เหมือนกันกับที่ได้จาก Server, ข้อผิดพลาดในการทำ Hydration มักเกิดจากสาเหตุเหล่านี้:
- ช่องว่างเพิ่มเติม (เช่น ช่องว่างหรือขึ้นบรรทัดใหม่) รอบๆ HTML ที่ React สร้างขึ้นภายใน root node
- ใช้การตรวจสอบแบบ
typeof window !== 'undefined'ในตรรกะการเรนเดอร์- ใช้ API เฉพาะสำหรับเบราว์เซอร์ เช่น
window.matchMediaในตรรกะการเรนเดอร์- การเรนเดอร์ข้อมูลที่แตกต่างกันระหว่าง Server และ Client
React อาจสามารถกู้คืนบางข้อผิดพลาดจากการ Hydration ได้ แต่คุณต้องแก้ไขปัญหาเหล่านี้เหมือนกับการแก้ไข Bug อื่น ๆ
เราสามารถอ่านข้อควรระวังและคำเตือนอื่น ๆ ได้จาก: hydrateRoot
Setup with Next.js
Next.js เป็น server-side rendering framework สำหรับ React ที่ใช้งานกันอย่างแพร่หลาย ที่ท้าทายการใช้งานกับ Zudtand จำไว้ว่า Zustand store เป็น global state ทำให้มันสามารถใช้ Context เป็นทางเลือกเท่านั้น
ความท้าทายมีดังต่อไปนี้
- Per-request store: Next.js รองรับการรับ request หลาย ๆ ตัว หลายความว่า store ควรที่จะสร้างขึ้น ต่อ 1 request และ ไม่ควรที่จะแชร์ข้อมูลข้าม request
- SSR friendly: Next.js application จะ render 2 รอบ ครั้งแรกที่ Server ครั้งที่ 2 ที่ Client ทำให้เกิดข้อแตกต่างระหว่าง Client กับ Server ขึ้น จึงเกิด hydration errors ขึ้นได้. Store ควรได้ข้อมูลจาก Server เป็นค่าเริ่มต้นก่อน หลังจากนั้น ควรได้ข้อมูลจาก Client ด้วยข้อมูลชุดเดียวกัน ในการป้องกันการเกิด Erorr เราควรอ่าน SSR and Hydration guide.
- SPA routing friendly: Next.js รองรับ hybrid model สำหรับ client routing นั่นหมายความว่า ถ้าเราต้องการจะ reset ค่า ของ store, เราต้องเปลี่ยนค่ามันจาก Component โดยใช้
Context - Server caching friendly: Version ปัจจุบันของ Next.js รองรับการทำ Caching. เนื่องจาก store ของเรา เป็น module state มันสามารถเข้ากันได้กับการ caching
คำแนะนำทั่วไปในการใช้งาน Zustand อย่างเหมาะสมกับ Next.js
- หลีกเลี่ยงการใช้ global store:
- Store ไม่ควรถูกแชร์ระหว่าง request ดังนั้นควรกำหนดค่า store ใหม่ต่อ request
- React Server Components (RSCs):
- RSCs ไม่สามารถใช้ hooks หรือ context ได้
- RSCs ไม่ควรอ่านหรือเขียนข้อมูลใน store เพราะจะละเมิดสถาปัตยกรรมของ Next.js
Creating a store per request
เรามาเริ่มเขียน store ที่จะสร้าง store ตัวใหม่ สำหรับ request แต่ละตัวกัน
// 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"]
}
// 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 ใน Component ของเราและแชร์ข้อมูลโดยใช้ Context กันเถอะ
// 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>()
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)
}
Initializing the 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 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 })),
}))
}
// 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>()
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
architectures สำหรับ Next.js application: Pages Router และ App Router. การใช้งานของ Zudtand บน architectures ทั้งสองตัว ควรเหมือนกัน แต่จะแตกต่างกันเล็กน้อย
Pages Router
// 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>
)
}
// 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>
)
}
// src/pages/index.tsx
import { HomePage } from '@/components/pages/home-page.tsx'
export default function Home() {
return <HomePage />
}
// 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
// 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>
)
}
// 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 />
}
// 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>
)
}