0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Reactを学ぶ Zustand

Posted at

状態の更新

フラットな状態の更新

フラットな状態の更新は簡単で、set 関数に新しい状態を渡すと、既存の状態と浅くマージされる。

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

ネストした状態の更新

ネストした状態を更新する場合、イミュータブルに更新する必要があるため少し工夫が必要になる。

type State = {
  deep: {
    nested: {
      obj: { count: number }
    }
  }
}

通常の方法

React や Redux と同様に、各階層をスプレッド構文 ... でコピーして、新しい値とマージする方法。

normalInc: () =>
  set((state) => ({
    deep: {
      ...state.deep,
      nested: {
        ...state.deep.nested,
        obj: {
          ...state.deep.nested.obj,
          count: state.deep.nested.obj.count + 1
        }
      }
    }
  })),

Immer を使う

Immer を使うと、ネストした状態も簡潔に更新できる。

immerInc: () =>
  set(produce((state: State) => { ++state.deep.nested.obj.count })),

イミュータブルな状態とマージ

useState と同様に、状態は不変的に更新する必要がある。
set 関数はストア内の状態を更新する。状態はイミュータブルなため、以下の様にするべき。

set((state) => ({ ...state, count: state.count + 1 }))

しかし、set 関数は状態を浅くマージするため、...state の部分はなくても問題ない。

set((state) => ({ count: state.count + 1 }))

ネストした状態

set 関数は浅くマージするため、ネストした状態は明示的にマージする必要がある。

import { create } from 'zustand'

const useCountStore = create((set) => ({
  nested: { count: 0 },
  inc: () =>
    set((state) => ({
      nested: { ...state.nested, count: state.nested.count + 1 },
    })),
}))

置き換えフラグ

set の第2引数に true を渡すと、既存状態との浅いマージを無効化して完全に置き換えることができる。

set((state) => newState, true)

Flux に着想を得た設計手法

Zustand は特定の設計思想を強制しない柔軟なライブラリだが、Flux や Redux に由来するいくつかの推奨パターンがある。そのため他の状態管理ライブラリに慣れている人でも馴染みやすい。ただし、Zustand には根本的に異なる点もあり、用語の意味が他のライブラリと完全には一致しない場合がある。

推奨されるパターン

アプリケーション全体の状態を 1 つのストアで管理する

アプリ全体のグローバルな状態は、基本的に 1 つの Zustand ストアにまとめるべきである。
ただし、アプリが大規模な場合は、Zustand の「スライス」機能を使ってストアを分割することもできる。

set(または setState)を使ってストアを更新する

ストアを更新するときは、常に set(または setState)を使う。
set は、更新内容を正しくマージし、変更をリッスンしているコンポーネントに通知されることを保証する。

ストアのアクションをストア内にまとめて定義する

Zustand では、Flux 系ライブラリのように アクションやリデューサーを使わなくても 状態を更新できる。
ストア内に直接アクション(更新関数)を定義でき、必要に応じて setState を使って ストアの外部に定義することもできる。

const useBoundStore = create((set) => ({
  storeSliceA: ...,
  storeSliceB: ...,
  storeSliceC: ...,
  updateX: () => set(...),
  updateY: () => set(...),
}))

Redux に似た設計パターン

Zustand では Redux のような reducer や dispatch を使わずに状態を更新できるが、どうしても Redux 風に書きたい場合は、自分で dispatch 関数と reducer を定義して同じ動作を再現できる。

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

また、Zustand には公式の redux ミドルウェアもあり、reducer・初期状態・dispatch を自動的にセットアップできる。

import { redux } from 'zustand/middleware'

const useReduxStore = create(redux(reducer, initialState))

さらに、副作用を含むような更新処理(例:HTTP リクエスト)を行いたい場合は、状態操作関数をラップした独自の関数を定義する方法もある。

セレクタの自動生成

Zustand では、ストアの状態やアクションを使う際に セレクタ関数 の利用が推奨される。

const bears = useBearStore((state) => state.bears)

ただし、毎回 (state) => state.xxx と書くのは面倒な場合があるため、セレクタを自動生成 して簡潔にアクセスできるようにできる。

createSelectors を作る

createSelectors.ts
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,
) => {
  const store = _store as WithSelectors<typeof _store>
  store.use = {}
  for (const k of Object.keys(store.getState())) {
    ;(store.use as any)[k] = () => store((s) => s[k as keyof typeof s])
  }

  return store
}

createSelectors は、Zustand ストアから 自動的にセレクター関数を生成 するためのユーティリティ関数。

たとえば、次のようなストアを用意するとする。

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

これを createSelectors に渡すと

const useBearStore = createSelectors(useBearStoreBase);

次のようにプロパティやアクションを直接呼び出せるようになる。

// get the property
const bears = useBearStore.use.bears();

// get the action
const increment = useBearStore.use.increment();

つまり、手動でセレクター (state) => state.xxx を書く必要がなくなり、各状態や関数を個別に簡潔に呼び出せる ようにする仕組み。

アクションを定義せずにストアを扱う練習

推奨される使い方は、アクションと状態をストア内にまとめて配置すること。つまり、状態とその操作関数(アクション)を同じ場所に置くという考え方。

export const useBoundStore = create((set) => ({
  count: 0,
  text: 'hello',
  inc: () => set((state) => ({ count: state.count + 1 })),
  setText: (text) => set({ text }),
}))

このようにすると、データ(状態)とアクションが一体化した自己完結型のストアが作られる。

もう一つの方法として、アクションをストアの外(モジュールレベル)で定義するやり方がある。

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

この方法の利点は次のとおり

  • アクションを呼び出すのにフック(useBoundStore())を使う必要がない
  • コード分割(code splitting)がしやすい

このパターンに特別な欠点はないが、状態とアクションをまとめて書ける自己完結型のスタイルを好む人もいる。

TypeScript の基本ガイド

Zustand は軽量な状態管理ライブラリで、特に React と組み合わせて使われる。
Zustand は reducer や context、煩雑なボイラープレートを避け、TypeScript と組み合わせることで、ストアの状態・アクション・セレクタを強い型付けで扱え、補完やコンパイル時の安全性も得られる。

型付きストア(state + actions)の作成

ここでは state(状態)actions(操作) を TypeScript の interface で定義している。
<BearState> というジェネリクス型を create に渡すことで、ストアの構造がこの型に厳密に一致するよう強制される。
つまり、フィールドを定義し忘れたり、型を間違えたりすると TypeScript がエラーを出す。
これにより、JavaScript と違って 型安全な状態管理 が保証される。

// store.ts
import { create } from 'zustand'

// Define types for state & actions
interface BearState {
  bears: number
  food: string
  feed: (food: string) => void
}

// Create store using the curried form of `create`
export const useBearStore = create<BearState>()((set) => ({
  bears: 2,
  food: 'honey',
  feed: (food) => set(() => ({ food })),
}))

React コンポーネントで型安全にストアを利用する方法

コンポーネント内では、ストアの状態を読み取ったり、アクションを呼び出したりできる。
(s) => s.bears のような セレクタ を使うと、必要な部分だけにサブスクライブでき、不要な再レンダーを防いでパフォーマンスを向上させられる。
JavaScript でも同じことはできるが、TypeScript では 状態フィールドの補完や型チェック が効くため、より安全で快適に扱える。

import { useBearStore } from './store'

function BearCounter() {
  // Select only 'bears' to avoid unnecessary re-renders
  const bears = useBearStore((s) => s.bears)
  return <h1>{bears} bears around</h1>
}

型安全にストアをリセットする方法

ログアウトやセッションのクリア時などに、ストアを初期状態に戻す「リセット」が役立つ。
typeof initialState を使うことで、型を二重定義せずに済み、initialState の構造が変わっても型が自動で更新される。
JavaScript より安全かつ保守性の高い書き方になる。

import { create } from 'zustand'

const initialState = { bears: 0, food: 'honey' }

// Reuse state type dynamically
type BearState = typeof initialState & {
  increase: (by: number) => void
  reset: () => void
}

const useBearStore = create<BearState>()((set) => ({
  ...initialState,
  increase: (by) => set((s) => ({ bears: s.bears + by })),
  reset: () => set(initialState),
}))

function ResetZoo() {
  const { bears, increase, reset } = useBearStore()

  return (
    <div>
      <div>{bears}</div>
      <button onClick={() => increase(5)}>Increase by 5</button>
      <button onClick={reset}>Reset</button>
    </div>
  )
}

ストア型の抽出と再利用(props・テスト・ユーティリティ向け)

ExtractState は Zustand に用意されている組み込みの型ユーティリティで、ストアの型(state と actions 両方)を自動で抽出できる。
テスト・ユーティリティ関数・コンポーネントの props などで再利用する際に便利。
手動で型を再定義する必要がなく、ストア構造の変更にも自動で追従する。

// store.ts
import { create, type ExtractState } from 'zustand'

export const useBearStore = create((set) => ({
  bears: 3,
  food: 'honey',
  increase: (by: number) => set((s) => ({ bears: s.bears + by })),
}))

// ストア全体の型を抽出
export type BearState = ExtractState<typeof useBearStore>

テストで使う例:

// test.cy.ts
import { BearState } from './store.ts'

test('should reset store', () => {
  const snapshot: BearState = useBearStore.getState()
  expect(snapshot.bears).toBeGreaterThanOrEqual(0)
})

ユーティリティ関数で使う例:

// util.ts
import { BearState } from './store.ts'

function logBearState(state: BearState) {
  console.log(`We have ${state.bears} bears eating ${state.food}`)
}

logBearState(useBearStore.getState())

セレクタの合成

複数の状態を同時に取得したい場合、セレクタ内でオブジェクトを返すことで複数フィールドにアクセスできる。
ただし、そのオブジェクトを直接分割代入すると、どれか一つの値が変わるたびに不要な再レンダーが発生する。
これを防ぐために、useShallow を使ってラップすると、浅い比較(shallow equal)によって値が実質的に変化していない場合は再レンダーを抑制できる。
この方法はストア全体を購読するより効率的で、TypeScript によってプロパティ名のタイプミスも防げる。

import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'

interface BearState {
  bears: number
  food: number
}

const useBearStore = create<BearState>()(() => ({
  bears: 2,
  food: 10,
}))

function MultipleSelectors() {
  const { bears, food } = useBearStore(
    useShallow((state) => ({ bears: state.bears, food: state.food })),
  )

  return (
    <div>
      We have {food} units of food for {bears} bears
    </div>
  )
}

派生状態の構築

すべての値をストアに直接保持する必要はない。既存の状態から導き出せる値は、セレクタを使って計算すればよい。
これにより重複を避け、ストアを最小限に保てる。
TypeScript によって bears が数値型であることが保証されるため、安全に計算できる。

import { create } from 'zustand'

interface BearState {
  bears: number
  foodPerBear: number
}

const useBearStore = create<BearState>()(() => ({
  bears: 3,
  foodPerBear: 2,
}))

function TotalFood() {
  // 導出値: 全てのクマに必要な食料量
  const totalFood = useBearStore((s) => s.bears * s.foodPerBear)

  return <div>We need {totalFood} jars of honey</div>
}

TypeScript 対応のミドルウェア

combine

このミドルウェアは、初期状態とアクションを分離して記述できるため、コードがより整理される。
TypeScript では状態とアクションの型を自動で推論するため、明示的にインターフェースを定義する必要がない
これは型安全性のない JavaScript とは異なり、TypeScript プロジェクトで非常に一般的なスタイル。

import { create } from 'zustand'
import { combine } from 'zustand/middleware'

interface BearState {
  bears: number
  increase: () => void
}

// 状態とアクションを分離して定義
export const useBearStore = create<BearState>()(
  combine({ bears: 0 }, (set) => ({
    increase: () => set((s) => ({ bears: s.bears + 1 })),
  })),
)

devtools

このミドルウェアは Zustand を Redux DevTools に接続するためのもの。
状態の変更を確認したり、タイムトラベル(過去の状態へ戻る)をしたり、状態をデバッグすることができる。開発中に非常に便利。

TypeScript を使うことで、ここでも アクションや状態に型チェックが適用されたままになる。

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

interface BearState {
  bears: number
  increase: () => void
}

export const useBearStore = create<BearState>()(
  devtools((set) => ({
    bears: 0,
    increase: () => set((s) => ({ bears: s.bears + 1 })),
  })),
)

persist

このミドルウェアは、ストアを localStorage(または別のストレージ)に保持する。
つまり、ページをリロードしてもクマ(bears)のデータが消えない。
永続化が重要なアプリに最適。

TypeScript では、状態の型が一貫して維持されるため、実行時に型の不整合などの予期せぬ問題が起きない。

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface BearState {
  bears: number
  increase: () => void
}

export const useBearStore = create<BearState>()(
  persist(
    (set) => ({
      bears: 0,
      increase: () => set((s) => ({ bears: s.bears + 1 })),
    }),
    { name: 'bear-storage' }, // localStorage key
  ),
)

型付き API レスポンスを扱う非同期アクション

アクションは非同期にしてリモートデータを取得することもできる。
この例ではクマの数を API から取得し、状態を更新している。

TypeScript は API レスポンスの型(BearData)を厳密にチェックするため、
たとえば count のスペルミスなど、JavaScript では見逃されるようなエラーを防いでくれる。

import { create } from 'zustand'

interface BearData {
  count: number
}

interface BearState {
  bears: number
  fetchBears: () => Promise<void>
}

export const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  fetchBears: async () => {
    const res = await fetch('/api/bears')
    const data: BearData = await res.json()

    set({ bears: data.count })
  },
}))

createWithEqualityFn(拡張版 create 関数)の使用

createWithEqualityFn は、状態取得時にカスタムの等価性チェックを組み込みたい場合に使う関数。
普段はあまり使わないが、Zustand の柔軟性を示す例。TypeScript でも型推論はそのまま有効。

import { createWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'

// カスタム等価性チェック付きのストア作成
const useBearStore = createWithEqualityFn(() => ({
  bears: 0,
}))

// Object.is を使った値の取得
const bears = useBearStore((s) => s.bears, Object.is)

// shallow を使ったオブジェクトの取得
const bearsObj = useBearStore((s) => ({ bears: s.bears }), shallow)

複数ストアの構成と連携

複数のストアを作ることで、異なるドメインの状態を分離して管理できる。
例えば BearStore はクマ、FishStore は魚を管理する。大規模アプリでは状態の分離により保守性が高まる。TypeScript を使うと、それぞれのストアに厳密な型が付くため、誤ってクマと魚を混ぜることも防げる。

import { create } from 'zustand'

// Bearストア
interface BearState {
  bears: number
  addBear: () => void
}

const useBearStore = create<BearState>()((set) => ({
  bears: 2,
  addBear: () => set((s) => ({ bears: s.bears + 1 })),
}))

// Fishストア
interface FishState {
  fish: number
  addFish: () => void
}

const useFishStore = create<FishState>()((set) => ({
  fish: 5,
  addFish: () => set((s) => ({ fish: s.fish + 1 })),
}))

// コンポーネント内で両方のストアを安全に使用
function Zoo() {
  const { bears, addBear } = useBearStore()
  const { fish, addFish } = useFishStore()

  return (
    <div>
      <div>{bears} bears and {fish} fish</div>
      <button onClick={addBear}>Add bear</button>
      <button onClick={addFish}>Add fish</button>
    </div>
  )
}

TypeScript の発展ガイド

一般利用しない機能の解説と判断し、多くを省略。

ミドルウェアの使用

TypeScriptでZustandのミドルウェアを使う場合、特別なことをする必要はない。

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()(
  devtools(
    persist(
      (set) => ({
        bears: 0,
        increase: (by) => set((state) => ({ bears: state.bears + by })),
      }),
      { name: 'bearStore' },
    ),
  ),
)

ポイントは、create の中でミドルウェアを使うこと。これによって型推論が正しく働く。

もし次のように一度関数にまとめるようなことをすると、型の扱いが少し複雑になる。

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

const myMiddlewares = (f) => devtools(persist(f, { name: 'bearStore' }))

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()(
  myMiddlewares((set) => ({
    bears: 0,
    increase: (by) => set((state) => ({ bears: state.bears + by })),
  })),
)

もうひとつの注意点は、devtools ミドルウェアはなるべく最後に使うこと。

例えば、immer と組み合わせる場合は

devtools(immer(...))

のようにする。

逆に

immer(devtools(...))

のようにしてしまうと、devtoolssetState に追加する型情報が他のミドルウェアで上書きされてしまう可能性があるため。

要するに、devtools は最後に置くことで、他のミドルウェアが先に setState を変更しても安全に使える。

Map と Set の使い方

MapSet はミュータブル(可変)なデータ構造のため、Zustand で更新する際は 必ず新しいインスタンスを作る 必要がある。

Map の場合

読み取り

const foo = useSomeStore((state) => state.foo)

更新

常に新しい Map インスタンスを作る

// 単一エントリの更新
set((state) => ({
  foo: new Map(state.foo).set(key, value),
}))

// エントリの削除
set((state) => {
  const next = new Map(state.foo)
  next.delete(key)
  return { foo: next }
})

// 複数エントリの更新
set((state) => {
  const next = new Map(state.foo)
  next.set('key1', 'value1')
  next.set('key2', 'value2')
  return { foo: next }
})

// クリア
set({ foo: new Map() })

ポイントは、state.foo を直接変更するのではなく、新しい Map を作って変更すること。
これにより Zustand の状態更新が正しくトリガーされる。

Set の場合

読み取り

const bar = useSomeStore((state) => state.bar)

更新

// アイテムの追加
set((state) => ({
  bar: new Set(state.bar).add(item),
}))

// アイテムの削除
set((state) => {
  const next = new Set(state.bar)
  next.delete(item)
  return { bar: next }
})

// アイテムのトグル(存在すれば削除、なければ追加)
set((state) => {
  const next = new Set(state.bar)
  next.has(item) ? next.delete(item) : next.add(item)
  return { bar: next }
})

// クリア
set({ bar: new Set() })

ポイントは、既存の state.bar を直接変更せず、新しい Set インスタンスを作って更新すること。
これで Zustand の状態更新が正しく反映される。

なぜ新しいインスタンスが必要か

Zustand は状態の変更を 参照(reference)で比較 して検出する。
そのため、MapSet を直接変更しても参照は変わらず、再レンダーは発生しない。

// ❌ NG - 同じ参照のまま、再レンダーされない
set((state) => {
  state.foo.set(key, value)
  return { foo: state.foo }
})

// ✅ OK - 新しい参照を作ることで再レンダーが発生
set((state) => ({
  foo: new Map(state.foo).set(key, value),
}))

ポイントは ミュータブルなオブジェクトを直接変更せず、新しいインスタンスを作ること
これにより Zustand が変更を検出し、コンポーネントが正しく再レンダーされる。

状態のリセット方法

単一ストアのリセット

状態を初期値に戻したい場合、以下のパターンを使う。

const useSomeStore = create<State & Actions>()((set, get, store) => ({
  // その他の状態やアクション
  reset: () => {
    set(store.getInitialState())
  },
}))

ポイントは、store.getInitialState() を使って初期状態を取得し、set で上書きすること。

複数ストアを一度にリセット

複数のストアをまとめてリセットしたい場合は、リセット関数をセットで管理すると便利。

import type { StateCreator } from 'zustand'
import { create as 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)
    storeResetFns.add(() => {
      store.setState(store.getInitialState(), true)
    })
    return store
  }
}) as typeof actualCreate

ポイントは、各ストア作成時にリセット関数を storeResetFns に追加しておき、まとめて呼び出すことで 全ストアを一度に初期化 できること。

Props で状態を初期化する方法

依存性注入が必要な場合、たとえばストアをコンポーネントの props で初期化する必要がある場合、推奨される方法は React.context を使ったバニラストア の利用になる。

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

React.createContext を使ったコンテキスト作成

import { createContext } from 'react'

export const BearContext = createContext<BearStore | null>(null)

基本的なコンポーネントでの使用方法

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

共通パターン

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

Context ロジックをカスタムフックに抽出

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

カスタム比較関数を使う場合

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

完全な例

// Provider wrapper & custom hook consumer
function App2() {
  return (
    <BearProvider bears={2}>
      <HookConsumer />
    </BearProvider>
  )
}

スライスパターン

ストアを小さなストアに分割する

ストアは機能を追加していくとどんどん大きくなり、管理が難しくなる。
メインのストアを小さな個別のストアに分けることでモジュール化が可能。Zustandではこれを簡単に実現できる。

最初の個別ストア:

export const createFishSlice = (set) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
})

もう一つの個別ストア:

export const createBearSlice = (set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
})

これで、両方のストアを1つの束縛されたストアにまとめることができる:

import { create } from 'zustand'
import { createBearSlice } from './bearSlice'
import { createFishSlice } from './fishSlice'

export const useBoundStore = create((...a) => ({
  ...createBearSlice(...a),
  ...createFishSlice(...a),
}))

React コンポーネントでの使用

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

複数のストアを更新する

1つの関数で、複数のストアを同時に更新できる。

export const createBearFishSlice = (set, get) => ({
  addBearAndFish: () => {
    get().addBear()
    get().addFish()
  },
})

すべてのストアをまとめる方法は前のものと同様。

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

ミドルウェアの追加

結合されたストアにミドルウェアを追加する方法は、通常のストアと同じ。

useBoundStorepersist ミドルウェアを追加する例:

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

useShallow で再レンダーを防ぐ

ストアから算出された状態にサブスクライブする必要がある場合、推奨される方法はセレクタを使うこと。
算出されたセレクタは、出力が Object.is によって変化した場合に再レンダーを引き起こす。
この場合、算出された値が常に前回と浅く等しい(shallow equal)場合に再レンダーを避けたいなら、useShallow を使うとよい。

あるストアがあり、各クマに対応する食事が格納されている。そしてクマの名前をレンダーしたい。

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

この変更は、names の実際の出力は浅く等しいにもかかわらず、BearNames を再レンダーさせてしまう。
これを 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>
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?