React向け 状態管理ライブラリ「Zustand」の紹介

今回紹介したいこと

突然ですが、皆さまはReactでフロントエンドを開発するうえで、状態をどのように管理されてますでしょうか?

「実績と信頼のあるReduxで管理している」
「外部ライブラリなんて必要ない、useContextで十分」
「recoilの様な分散型の状態管理を行いたい」

いろいろなご意見があると思います。

どの方法も一長一短で、ベストプラクティスが無い状況の様な気がします。

私は以前にReduxを使用した経験から、Reduxの素晴らしさを理解しつつも、Fluxに基づいたアーキテクチャーの理解や、ボイラープレートコードの記述量の多さに煩わしさを感じ・・・

「Reduxは数年後には別のライブラリーにシェアを奪われて消えるに違いない」

と考えておりましたが、現実はそうなりませんでした。

依然としてReduxは人気No1を維持しつつ、その他状態管理ライブラリが乱立する状態(状態管理の戦国時代)となっているという認識です。

そのような状態の中、ミニマムかつ手軽に状態管理が行えるトップダウン系の状態管理ライブラリー「Zustand」が 2021年末頃から着実に人気を伸ばしてきています。

今回は、状態管理ライブラリー「Zustand」について、簡単なコードも交えながら紹介させて頂きます。

pmndrs/zustand: 🐻 Bear necessities for state management in React

状態管理ライブラリー「Zustand」

Poimandresのメンバーが共同開発している状態管理ライブラリーの1つです。

Zustandはドイツ語で「状態」という意味の様で、Reduxの流れを汲むトップダウン系です。

人気のボトムアップ型の状態管理ライブラリー「Jotai」とは兄弟にあたります。

手軽に状態管理が実装できるため、2021年から人気を集め、npmでのダウンロード数は「recoil」以上です。

ちなみに、Reduxは現在も圧倒的人気なので比較はできませんね。。。

また、バンドルサイズがその他の状態管理ライブラリーと比較し、ダントツで小さく最小で1.1kbしかありません。

これは、軽量だと言われているJotaiの3.4kbの1/3以下です。

凄いですね。

 ※ちなみにReduxも単体ではわりと軽量

また、仕様がとてもシンプルでドキュメントも小ぶりのため、学習コストを抑える事ができます。

Reduxとは同系列であるため、他のライブラリと比べるとReduxから移行しやすい利点もあります。

環境を準備してみる

それでは、Zustandを使用して簡単なカウンターアプリを実装してみましょう。

今回は、ホットリロードがチョッパヤだと噂のViteを使用してプロジェクトを作成してみたいと思います。

 ※手順は既にnode.js(本記事執筆時はv18.12.1を使用)がインストール済みである事を前提にしております。

まずはプロジェクトを作成します。

npm create vite@latest zustand-app

以下の様にフレームワークの選択画面が表示されますので、今回は「React」を選択します。

ViteがReact構成のプロジェクトを作成してくれます。

プロジェクト(zustand-appディレクトリ)が出来上がりますので、ディレクトリを移動し、各種モジュールとZustandとインストールします。

 ※Zustandはv4.1.4を使用しています。

cd zustand-app
npm install
npm install zustand

App.jsxを以下で置き換えてください。

import create from 'zustand'
import './App.css'

const useStore = create((set, get) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => {
    if(get().count !== 0) {
      set({ count: 0 })
    }
  }
}))

function App() {
  const count = useStore((state) => state.count)
  const increment = useStore((state) => state.increment)
  const decrement = useStore((state) => state.decrement)
  const reset = useStore((state) => state.reset)
  return (
    <div>
      {count}
      <div>
        <button onClick={() => increment()}>increment</button>
        <button onClick={() => decrement()}>decrement</button>
        <button onClick={() => reset()}>reset</button>
      </div>
    </div>
  )
}

export default App

実行します。

npm run dev

コンソール上に表示されたURLでブラウザで開くと、以下のような画面が表示されます。

判りにくいですが各ボタンをクリックすると、ステートが変化し上部の数値が増減します。

以上で環境が用意できました。

次回でApp.jsxに記述されたZustandのコードを1つずつ説明していきます。

サンプルコードの説明

それでは、App.jsxのサンプルコードの内容を1つずつ見ていきます。

create関数

関数にストアー定義を渡すと、ストアー管理用のHookが戻り値として返されます。

Zustandでは、create関数を使用してストアーを定義し、戻り値のHookでステートを操作等をおこないます。

ストアー定義はとてもシンプルで、ステートと(ステートを変更するための)アクションを一緒に定義できます。

ステートとアクションは全てここにまとめる事ができますね。

const useStore = create((set, get) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => {
    if(get().count !== 0) {
      set({ count: 0 })
    }
  }
}))

本サンプルでは、countはステート、increment、decrement、resetがアクションになります。

アクション内でステートを変更するためには、引数のset関数を使用します。

set関数はステートを変更するだけではなく、渡された値をストアー内のステートに、イミュータブルにマージしてくれます。

 ※Zustandにおいても、Reduxと同じくステートはイミュータブルなのです。

 ※Set関数がマージしてくれるのは、オブジェクトの第1階層のみのようです。

ReduxのReducerでよくある、以下の様な書き方(分割代入)をする必要がないので楽ですね。

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

また、アクション内では非同期処理を行う事も出来ます。

ただ、Zustandの非同期は限定的で他のライブラリーの様にSuspenseに対応していたり・・・といった事はありません。

この辺りはZustandの開発思想でもあるようです。

increment: async () => {
  const no = await fetch()
  set((state) => ({ count: state.count + no }))
}

最後になりますが、get関数からは現在のステートを取得する事が可能です。

アクション内の判定などに使用できたりします。

reset: () => {
  if(get().count !== 0) {
    set({ count: 0 })
  }
}

Hookによるコンポーネントとのバインドについて

ストアーを作成したので、今度はストアー内のステート処理をコンポーネントと紐づけましょう。

そちらはcreate関数の戻り値のHookを使用して行います。

今回のサンプルでは「useStore」ですね。

create関数で定義したステートやアクションは、どちらも以下の様にHookから取得可能です。

コードではステートやアクションを個別に取得していますが、これをZustandではセレクターと呼びます。

const count = useStore((state) => state.count)
const increment = useStore((state) => state.increment)
const decrement = useStore((state) => state.decrement)
const reset = useStore((state) => state.reset)

セレクターを使用してステートを取得した場合は、取得したステートの変更時のみコンポーネントの再レンダリングが実行される仕組みとなっています。

これは、画面のレンダリング回数を減らすうえで、とても重要な仕組みですので、極力セレクターを使用する様にしましょう。

 ※useContextでの状態管理では、不要な再レンダリングが発生する場合があるようで、こういった機能は大切です。

以下のように、ストアー全てを取得する事も推奨しませんが可能です。

const store= useStore()

また、Hookの第2引数で、ステート変更時の再レンダリング条件を指定する事なども可能だったり、複数のステートやアクションを一度で取る方法もあるようです。

コードの説明は以上になります。

Zustandでの状態管理は、useStateのようにとてもとっつきやすく簡単である事を知って頂けたかと思います。

create関数とHookには、まだ紹介しきれなかった機能がございますので、気になった方は是非公式ドキュメントを見て頂けたらと思います。

それでは最後に、Zustandの特徴的な機能をいくつか紹介していきたいと思います。

Zustandの機能紹介1:Subscribe

Hookのsubscribeメソッドを使用すると、ステートの変更を検知し、任意の処理を実行する事ができます。

ステートの変更と同期した処理や、Reactのレンダリング管理外の処理などを簡単に実装することができます。

ストアーのステート全体の変更を検知する

以下の例は、前回作成したuseStoreに関連したストアー内のステートの変更を検知し、console.logで変更前後のストアーの状態をログ出力しています。

useStore.subscribe((after, before) => console.log('change state: ', before, after))

ストアーの特定のステートの変更を検知する

ストアー全体ではなく、特定のステートの変更を検知することも可能です。

その場合は、後ほど紹介するミドルウェアのsubscribeWithSelectorを使用します。

以下の例は、countステートが変更されると、console.logでステートの変更前後の内容をログ出力しています。

const useStore = create(subscribeWithSelector((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
})))
useStore.subscribe((state) => state.count, (a, b) => console.log('change count: ', b, a))

検知の停止

subscribeメソッドの戻り値を関数呼び出しすると、検知の停止する事ができます。

const unsub = useStore.subscribe(...)
unsub()

Zustandの機能紹介2:ミドルウェア

create関数に渡すストアー定義をラップする事で、ステートやアクションに任意の処理を挟むことができます。

自前でミドルウェア用のラッパー関数を作成する事も勿論可能ですが、Zustandには既に便利なミドルウェアがいくつか用意されています。

その中の一部を紹介していきたいと思います。

persistミドルウェア

特定のインターフェースを実装したオブジェクトをpersistミドルウェアに渡すことで、ステート保存場所をメモリー以外のストレージ等に切り替えることができます。

例えば、ステートの状態をlocalStorageやIndexedDB、さらにはlocation.hashに持たせるなんて事も可能です。

こちらは紹介のみに留めますので、興味のある方は公式サイトのドキュメントをご確認ください。

devtoolsミドルウェア

状態管理ライブラリーは状態を管理するだけでなく、状態の変化をリアルタイムで確認できるようなサポート機能の有無も重要です。

Zustandには、devtoolsミドルウェアでラップするだけで、Redux devtoolsが簡単に使用可能になります。

以下の例では、incrementやdecrementなどのアクション実行時のステートの状態変化が、Redux devtoolsで確認できるようになります。

const useStore = create(devtools((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }))
})))

また、set関数の第3引数にアクションタイプを表す文字列を指定することができます。

Redux devtools上で、どのアクションが実行されたかを素早く確認したい時に便利です。

const useStore = create(devtools((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 }), false, 'incrementしたよ'),
  decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrementしたよ'),
  reset: () => set({ count: 0 }, false, 'resetしたよ'))
})))

以下の様にアクションタイプが表示されます。

immerミドルウェア

ZustandのステートはReduxと同じくイミュータブルです。

set関数はステートをイミュータブルにマージしてくれますが、それは第1階層のみと限定的です。

それ以上の深い階層は、アクション内で独自に対応する必要がありますが、immerを使用することで簡単にイミュータブルを実現することが可能です。

immerはイミュータブルな状態を簡易に実現できるライブラリーですが、Zustandではミドルウェアとして使用できます。

const useBeeStore = create(
  immer((set) => ({
    bees: 0,
    addBees: (by) =>
      set((state) => {
        state.bees += by
      }),
  }))
)

lodashのcloneDeepよりも効率的な様です。

const useBeeStore = create(
  immer((set) => ({
    bees: 0,
    addBees: (by) =>
      set((state) => {
        state.bees += by
      }),
  }))
)

Zustandの機能紹介3:スライス

Reduxでは、全ての状態を1つのストアーで一元管理します。

開発が進むにつれ、管理したいステートが増えていき、ストアーがどんどん肥大化していきます。

そのような場合、ストアー内のステートやアクションをモジュール化できると管理がしやすくなります。

Zustandでは、スライスとしてそれらを分割する事ができます。

例としてアクションとステートを一組としたスライスを2つ作成してみます。

1つは熊をカウントする熊カウンター、もう1つはウサギをカウントするウサギカウンターです。

const bearCounterSlice = (set) => ({
  bear: 0,
  bearIncrement: () => set((state) => ({ bear: state.bear + 1 })),
  bearDecrement: () => set((state) => ({ bear: state.bear - 1 })),
})
const rabbitCounterSlice = (set) => ({
  rabbit: 0,
  rabbitIncrement: () => set((state) => ({ rabbit: state.rabbit + 1 })),
  rabbitDecrement: () => set((state) => ({ rabbit: state.rabbit - 1 })),
})

この2つのスライスを1つにまとめてストアーを作成できます。

このストアーは2つのスライスのステートとアクションが利用可能です。

const bearAndRabbitStore = create((...a) => ({
  ...bearCounterSlice(...a),
  ...rabbitCounterSlice(...a),
}))

コンポーネント側では、セレクターで通常通りにアクションやステートの取得がおこなえます。

const bear = bearAndRabbitSore((state) => state.bear)
const bearIncrement = bearAndRabbitSore((state) => state.bearIncrement)
const bearDecrement = bearAndRabbitSore((state) => state.bearDecrement)
const rabbit = bearAndRabbitSore((state) => state.rabbit)
const rabbitIncrement = bearAndRabbitSore((state) => state.rabbitIncrement)
const rabbitDecrement = bearAndRabbitSore((state) => state.rabbitDecrement)

また、スライス同士を組み合わせて新しいスライスを作るなんて事も可能です。

以下の例では、熊とウサギを同時にカウントするアクションをスライスとして作成しています。

const bearAndRabbitIncrementSlice = (set) => ({
  bearAndRabbitIncrement: () => { 
    bearCounterSlice(set).bearIncrement()
  rabbitCounterSlice(set).rabbitIncrement()
  },
})

最後に

 いかがでしたでしょうか?

 今回の紹介で、少しでもZustandに興味を持っていただけたら嬉しいです。

 とてもシンプルで導入もしやすく手軽に状態管理を実装する事ができる、素晴らしいライブラリーだと思います。

 ただ、Zustandが全ての状態管理ライブラリーより優れている訳ではありません。

 大規模なアプリケーション開発では経験者も多く実績もあるRedux、比較的小さなアプリケーションではZustandやJotaiの様なシンプルでライブラリーを使用など、プロダクトに適したものを精査する必要があります。

 皆様も是非、Reactで個人アプリを開発する機会がありましたら、「どのように状態を管理すると良いか」を考えながら作ってみてください。