🔰

Zustandのベストプラクティスについて

2025/01/03に公開

この記事について

Zustandのベストプラクティスについては公式のドキュメントに解説があります。
ただし、ちょっぴり(私のような)初心者向けではなかったので、少し踏み込んで調べた内容を共有しよう!というのがこの記事の目的です。

スライスパターン

公式の解説ページ
シングルページアプリケーション(以下、SPF)ではしばしば、状態管理が複雑になりがちです。
複雑になる原因はいくつかあるのですが、その代表的な原因の一つが「単一のストアで多くの状態を管理する」ことです。

ストアとは

ストアとは「データ(状態)を管理する場所 」のことを指します。
これはReduxなどの状態管理ツールでも頻繁に使用される表現なので、ぜひ覚えておいてください。
ちなみに、、、

  • 「ストア(store)」という表現に違和感を感じる人もいるかもしれません。AppStoreとかって表現で使うstoreには「店舗」というイメージが強いですよね。
  • ただ、英語のstoreには「倉庫」という意味もあります。
  • 「倉庫」と言われると「データ(状態)を管理する場所 」とイメージがつながりやすいかもしれないですね。
  • ちなみに、、、の、ちなみに、、、
    • 「倉庫」といえば「strage」じゃないの?って方がいるかもしれません。
    • 実はstorageとstoreは語源的にかなり近いですが、同じではありません。
    • 使い分けとしては
      • strage→名詞として使う。「倉庫」「保管する場所」の意。やや抽象的な表現にも使う。
      • store→名詞として使う+動詞的用法もする。「倉庫」(名詞)「保管する場所」(名詞)「保存する」(動詞)「蓄える」(動詞)。
    • として分けるといいかもしれません。

当然のことながら、一つの管理場所で色々なことを管理するのは割と大変です。

  • 管理するファイルが膨大になって開発も保守も大変、、
  • 異なる状態同士の依存関係が増えて、変更時にどの状態が影響を受けるのか把握するのが困難に、、、
  • 小さな変更を加えただけで、すべての関連するロジックに影響が及ぶ可能性が、、、テストやデバッグで死ぬ。
  • など

そこで、「ストアを小さな単位に分割し、モジュール化しようね〜」というのがスライスパターンです。
たいていは機能ごとにストアを分割(スライス)します。
各スライスは独立した状態とアクションを持ちます。独立しているのが大切なので、無闇に他の状態管理と結合させないことが望ましいです。

具体的な方法

小さく管理

まず、原則として一つのストアが管理する状態をできるだけシンプルにします。
個人的にはこのコードを初めて見た人がひと目見ただけで何が管理されているstoreなのかを理解できるくらいの量を単一のstoreで管理するのがが望ましいのではないかと思っています。
公式の具体例を見てみましょう。

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

統合ストア

スライスされたストアがそのままスライスされたストアとして存在しているだけだと、扱いはまだ面倒です。
そこで、スライスしたストアを結合させて、各Reactコンポーネントでは別々のスライスを意識することなく使えるようにします。
この仕組みを「one bounded store」と呼びます。

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

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

使い方は至ってシンプルです。
one bounded storeによってかなり呼び出しが1箇所だけになっているのがわかりますね。

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

Fluxなどの思想の影響

Zustandは特定の設計スタイルに縛られないライブラリですが、Flux(状態管理の思想)やReduxのような状態管理ライブラリのベストプラクティスを活用できます。

Flux とは

FluxとはFacebook社が提唱しているクライアントサイドのWebアプリケーション開発のためのアプリケーション・アーキテクチャ(設計思想)です。
詳しく説明するにはこの記事では余白が足りない(説明するほどの理解度でもない)ので、詳細に知りたい方は[こちらの記事](https://qiita.com/knhr__/items/5fec7571dab80e2dcd92) などを参照なさってください。

FluxやReduxの経験がある人には馴染みやすいですが、Zustand独自の特徴もあるため、完全に同じ用語や仕組みではないので注意が必要です。

推奨パターン

公式の解説ページ

単一のストア

アプリ全体の状態(グローバルな状態)は、基本的に単一のZustandストアにまとめて管理します。
アプリが大規模な場合は「スライス」に分割することが推奨されます(詳細は前述のスライスパターン参照)。

set / setStateで状態を更新

Zustandでは、状態を更新する際は常に set または setState を使用します。
これにより、更新が正しくマージされ、リスナー(状態の変化を監視するもの)が適切に通知されます。
ここの使用感はReactにおけるuseStateと特に変わらないので、それのグローバル版だと思ってください。

ストアのアクションをコロケーション

これはZustand特有の思想です。
他のFluxライブラリ(例: Redux)ではアクションやリデューサーが必要ですが、Zustandでは不要です。
状態を直接更新するアクションをストア内に記述できます。

const useBoundStore = create((set) => ({
  valueA: 0,
  valueB: 0,
  updateValueA: () => set((state) => ({ valueA: state.valueA + 1 })),
}))

※ 必要に応じて、setState を使ってストア外部にアクションを配置することも可能。

React 18以前での使用について

公式の解説ページ
React 18以前の状態で、Reactのイベントハンドラー外で状態を更新する際の注意点と、それを安全に行う方法を紹介します。
最新のReactを使用していたら問題ないかもしれませんが、互換性を保つために知っておくと便利なので、一読をお勧めします。(ただし、若干難易度が上がるので、飛ばしても大丈夫です。)

状態更新の問題

Reactでは、状態更新 (setState) は通常同期的に処理されます。
イベントハンドラー外で状態を更新すると、Reactがすぐに再描画を行います(同期的な更新)。
この挙動が原因で「ゾンビ子効果 (zombie-child effect)」という問題が発生することがあります。

ゾンビ子効果とは?

状態の更新とコンポーネントの再レンダリングがずれてしまい、一部のコンポーネントが古い状態を使い続ける問題。

  • 例えば、状態が変わった後でも一部の子コンポーネントがその変化に追従しないケース。

解決策

unstable_batchedUpdates を使用
Reactは複数の状態更新を「バッチ処理(まとめて処理)」する機能を提供しています。
通常、Reactのイベントハンドラー内では自動的にバッチ処理が行われますが、ハンドラー外ではこの機能が働きません。
unstable_batchedUpdates を使用すると、ハンドラー外でもバッチ処理を強制できます。

コード例

以下のように実装します:

import { unstable_batchedUpdates } from 'react-dom' // または 'react-native'

// Zustandのストア
const useFishStore = create((set) => ({
  fishes: 0,
  increaseFishes: () => set((prev) => ({ fishes: prev.fishes + 1 })),
}))

// Reactの外で状態を更新する関数
const nonReactCallback = () => {
  unstable_batchedUpdates(() => {
    // 状態の更新をバッチ処理
    useFishStore.getState().increaseFishes()
  })
}

まとめ

React 18以前では、イベントハンドラー外で状態を更新する際に同期的な更新のリスクがありました。この問題を解決するために、unstable_batchedUpdates を使って安全にバッチ処理を適用します。

その他公式が紹介しているもの

公式のベストプラクティスについての章はこの内容で以上でしたが、別の事柄についても公式が解説をしています。
基本的に公式のやり方はとても正しいことが多いので、いくつか紹介しておこうと思います。

状態のリセット

公式の紹介ページ
アプリの状態を初期値に戻したい場面などに状態をリセットすることがあると思いますが、その手法についてまとめられています。

基本的な状態リセット

初期状態を定義

リセットするために、まず「初期状態」を定義します。

type State = {
  salmon: number
  tuna: number
}

const initialState: State = {
  salmon: 0,
  tuna: 0,
}

State: 状態の構造を定義。
initialState: 初期値をまとめた定数。

ストアの作成

Zustandで状態管理を行うストアを作成し、リセット用の関数を追加します。

import { create } from 'zustand'

const useSlice = create<State & Actions>()((set, get) => ({
  ...initialState, // 初期状態を展開
  addSalmon: (qty: number) => {
    set({ salmon: get().salmon + qty }) // salmonを追加
  },
  addTuna: (qty: number) => {
    set({ tuna: get().tuna + qty }) // tunaを追加
  },
  reset: () => {
    set(initialState) // 状態を初期値にリセット
  },
}))

reset 関数: 初期状態 initialState を set に渡すことで、状態をリセット。

使用例

const { addSalmon, reset } = useSlice.getState()

addSalmon(5) // salmonが5増加
reset()      // salmonとtunaが初期値に戻る

複数ストアの一括リセット

複数ストアの課題

アプリが複数のストア(状態管理の単位)を持つ場合、各ストアに個別でリセット処理を書くのは手間がかかります。
これを効率的に行う方法があります。

一括リセットの仕組み

storeResetFns: リセット用の関数を管理するセット。
各ストアが初期状態を保存し、リセット時にそれを適用します。

import { create as actualCreate } from 'zustand'

const storeResetFns = new Set<() => void>() // リセット関数を管理するセット

const resetAllStores = () => {
  storeResetFns.forEach((resetFn) => {
    resetFn() // 全てのリセット関数を実行
  })
}

export const create = (<T>() => {
  return (stateCreator) => {
    const store = actualCreate(stateCreator) // 実際のストアを作成
    const initialState = store.getState() // 初期状態を取得
    storeResetFns.add(() => {
      store.setState(initialState, true) // 初期状態にリセット
    })
    return store
  }
}) as typeof actualCreate

使用例

複数ストアを作成しても、一括リセットが可能です。

const useStore1 = create((set) => ({ value: 0, reset: () => set({ value: 0 }) }))
const useStore2 = create((set) => ({ count: 10, reset: () => set({ count: 10 }) }))

resetAllStores() // すべてのストアを初期化

状態の不変性(Immutable State)と更新の方法

公式の解説ページ

Zustandの状態更新の基本

(前述の内容とちょっと重複します。)
Zustandの set 関数は、Reactの useState に似た使い方で状態を更新します。
状態を更新する際、不変性(Immutable)を保つ必要があります。
基本的な状態更新
以下の例では、カウンターの値を1増加させています。

import { create } from 'zustand'

const useCountStore = create((set) => ({
  count: 0, // 初期値
  inc: () => set((state) => ({ count: state.count + 1 })), // 状態を更新
}))

set 関数:
Zustandの状態を更新するための関数。
現在の状態 (state) を引数として受け取り、新しい状態を返します。
{ count: state.count + 1 }:
カウンター値を1増加させる新しい状態を返す。

Zustandのマージ(Merging)動作

省略可能なスプレッド演算子

通常、状態は不変性を保つためにスプレッド演算子を使って全体をコピーする必要があります。

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

しかし、Zustandの set は1階層のみ自動で状態をマージするため、以下のように簡略化できます。

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

ネストされたオブジェクトの状態更新

Zustandの set は1階層のみマージするため、ネストされたオブジェクトを更新する場合は自分でマージが必要です。
ネストされた状態を持つ例

const useCountStore = create((set) => ({
  nested: { count: 0 }, // ネストされたオブジェクト
  inc: () =>
    set((state) => ({
      nested: { ...state.nested, count: state.nested.count + 1 }, // ネストをマージ
    })),
}))
{ ...state.nested, count: state.nested.count + 1 }:

ネストされたオブジェクトをスプレッド演算子で展開し、特定の値(count)を更新。
ネストが深い場合や複雑な操作が必要な場合は、immer のようなライブラリを検討すると便利です。

マージ動作を無効化(Replace Flag)

状態のマージ動作を無効化し、状態全体を置き換えたい場合は、set 関数の2番目の引数に true を指定します。
例: 状態全体を置き換える

set((state) => newState, true)
true:

マージ動作を無効化し、newState で状態全体を完全に置き換える。

用途:

状態全体を初期化したい場合や、すべてのプロパティを上書きしたい場合に有効。
まとめ

基本動作:

set 関数を使って状態を更新し、1階層のマージは自動で処理される。

ネストされた状態:

ネストされたオブジェクトは自分でスプレッド演算子を使ってマージ。
複雑な場合は、immer のようなライブラリを利用。

マージ無効化(置き換え):

状態全体を完全に置き換えたい場合は、set の2番目の引数に true を指定。

この柔軟性のおかげで、Zustandは小規模から中規模の状態管理に特に適しています。

状態とアクションの配置方法

公式の解説ページ

アクションと状態のコロケーション(同一ストア内での定義)

コロケーションの例

Zustandでは、状態とアクションを1つのストア内にまとめて定義するのが推奨されています。

export const useBoundStore = create((set) => ({
  count: 0, // 状態
  text: 'hello', // 状態
  inc: () => set((state) => ({ count: state.count + 1 })), // アクション
  setText: (text) => set({ text }), // アクション
}))

状態とアクションがストアに一緒に存在するため、管理が簡単。
「自己完結型」のストアが作れる。

使用例

const count = useBoundStore((state) => state.count)
const inc = useBoundStore((state) => state.inc)

console.log(count) // 現在のcount値を表示
inc()              // countを1増加

外部アクションの定義(モジュールレベルでのアクション分離)

外部アクションの例

状態(ストア)とアクションを分離して定義する方法。

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 のフックを必要としない。

使用例

import { useBoundStore, inc } from './store'

console.log(useBoundStore.getState().count) // 現在のcount値を表示
inc()                                       // countを1増加

比較と使い分け

コロケーションのメリット

自己完結型:

状態とアクションが1つの場所にまとまっているため、分かりやすい。

メンテナンス性が高い:

新しいアクションや状態を追加する際に一元管理できる。
外部アクションのメリット

フック不要:

アクションを呼び出す際に、Reactコンポーネント内で useBoundStore フックを使う必要がない。

コード分割が容易:

状態とアクションをモジュールごとに分割でき、大規模なプロジェクトで便利。

注意点

外部アクションを使う場合、状態とアクションが離れるため、どの状態がどのアクションと関係しているか分かりにくくなる場合があります。
状況に応じて使い分けるのがおすすめです。

推奨される選択肢

初期段階や小規模なプロジェクトでは、コロケーション(ストア内でアクションを定義)がおすすめ。
大規模プロジェクトやコード分割が必要な場合は、外部アクションを使用する方法が便利。

まとめ

状態とアクションをまとめる(コロケーション):

自己完結型で管理が簡単。

状態とアクションを分離する(外部アクション):

フック不要で、コード分割がしやすい。

Discussion