../zustand-react-state-library-thoughts

書き味がシンプルな状態管理ライブラリ「Zustand」を紹介

はじめに

少し古い話題ではありますが、昨年末に公開された 2023 JavaScript Rising Stars1状態管理部門で1位になった Zustand をサンプルコードを交えながら紹介したいと思います。

Zustand

公式ドキュメントにおいて Zustand は Redux のような状態管理ライブラリの一つで、小型・軽量・スケーラブルなライブラリであると紹介されています。

その他にも、学習曲線が比較的緩やかであることや少ない記述で状態管理を実現でき React のコンテキストに依存せずコンポーネント外部でも状態の更新・取得が可能であるといった特徴も持ち合わせています。

インストール

今回は pnpm + vite + react + TypeScript な環境を作って Zustand を動かします。

pnpm create vite@latest 

コマンドを実行するとプロジェクト名の入力やテンプレートの選択肢が表示されるので適当なプロジェクト名を入れて React + TypeScript なプロジェクトを作成します。

cd {作ったプロジェクト名}
pnpm install
pnpm dev
初期画面

この画面が出たらプロジェクトの準備は終わりです。 続いて Zustand を追加。

pnpm add zustand

ここまでくれば Zustand が使える状態になっているのでかんたんなコードを書いて実際に使ってみます。

ストアの作成

上記の手順をすべて終わらせるとプロジェクトディレクトリはこのようになっています。

.
├── README.md
├── index.html
├── node_modules/
│   └── 省略
├── package.json
├── pnpm-lock.yaml
├── public/
│   └── vite.svg
├── src
│   ├── App.css
│   ├── App.tsx
│   ├── assets/
│   ├── index.css
│   ├── main.tsx
│   └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

今回はよくある数値を増減、リセットするような動きを Zustand を使って作ってみます。

./src/store/useCountStore.ts を作成します。内容は次の通りです。

import { create } from "zustand";

interface CounterState {
  count: number,
  increment: () => void,
  decrement: () => void,
  reset: () => void,
}

export const useCountStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({count: state.count + 1})),
  decrement: () => set((state) => ({count: state.count - 1})),
  reset: () => set(() => ({count: 0}))
}));

数値増減くらいであればこれだけ書けばストアの完成です。シンプルですね。

ストアの使用

続いて使う方ですが、次のファイルを作成します。 コンポーネントの分割粒度や命名、ディレクトリ構成については雑に使ってみることを目的としているので大目に見てください。

Zustand の特徴としてストアを hook のように使えるというものがあるのであえてコンポーネントを細切れにしました。それぞれのファイル内容は次の通り。

CountNumber.tsx

import { FC } from "react";
import { useCountStore } from "../stores/useCountStore";
   
const CountNumber: FC = () => {
  const count = useCountStore((state) => state.count);

  return (
    <h2>{count}</h2>
  )
} 

export { CountNumber };

DecrementCountButton.tsx

import { FC } from "react";
import { useCountStore } from "../stores/useCountStore";
   
const DecrementCountButton: FC = () => {
  const decrementCount = useCountStore((state) => state.decrement);

  return (
    <button onClick={decrementCount}>-</button>
  )
} 

export { DecrementCountButton };

IncrementCountButton.tsx

import { FC } from "react";
import { useCountStore } from "../stores/useCountStore";
   
const IncrementCountButton: FC = () => {
  const incrementCount = useCountStore((state) => state.increment);

  return (
    <button onClick={incrementCount}>+</button>
  )
} 

export { IncrementCountButton };

ResetCountButton.tsx

import { FC } from "react";
import { useCountStore } from "../stores/useCountStore";
   
const ResetCountButton: FC = () => {
  const resetCount = useCountStore((state) => state.reset);

  return (
    <button onClick={resetCount}>reset</button>
  )
} 

export { ResetCountButton };

そして最後に ./src/App.tsx を少し編集してこれらを配置します。

App.tsx

import "./App.css";
import { Counter } from "./components/Counter";
import { IncrementCountButton } from "./components/IncrementCountButton";
import { DecrementCountButton } from "./components/DecrementCountButton";
import { ResetCountButton } from "./components/ResetCountButton";

function App() {

  return (
    <>
      <div className="card">
        <Counter/>
        <IncrementCountButton/>
        <DecrementCountButton/>
        <ResetCountButton/>
      </div>
    </>
  )
}

export default App

初期生成されたファイルを軽くいじっただけです。 これで各ボタンを押すとストアの値が更新されることがわかります。

スライスパターン

今回は数値の増減だけといったシンプルな用途で試しましたが、プロダクトで使う場合ストアはプロダクトの成長とともに肥大化していくことがあります。 その対応として Zustand でもスライスを作って分割することができます。

次のファイルを新しく作ります。

sliceTypes.ts

export type StateA = {
  countA: number
}
  
export type ActionA = {
  incrementA: () => void,
  decrementA: () => void,
  resetA: () => void,
}
  
export type SliceA = StateA & ActionA;

export type StateB = {
  countB: number,
}
  
export type ActionB = {
  incrementB: () => void,
  decrementB: () => void,
  resetB: () => void,
}
  
export type SliceB = StateB & ActionB;

export type Shared = {
  resetAll: () => void,
}

sliceA.ts

import { StateCreator } from "zustand";
import { StateA, SliceA, SliceB } from "./sliceTypes";

const initialStateA: StateA = {
  countA: 0,
}

const createSliceA: StateCreator<
  SliceA & SliceB,
  [],
  [],
  SliceA
> = (set) => ({
  countA: 0,
  incrementA: () => set((state) => ({ countA: state.countA + 1 })),
  decrementA: () => set((state) => ({ countA: state.countA - 1 })),
  resetA: () => set(initialStateA)
})

export { createSliceA, initialStateA };

sliceB.ts

import { StateCreator } from "zustand";
import { StateB, SliceA, SliceB } from "./sliceTypes";

const initialStateB: StateB = {
  countB: 0,
}

const createSliceB: StateCreator<
  SliceA & SliceB,
  [],
  [],
  SliceB
> = (set) => ({
  countB: 0,
  incrementB: () => set((state) => ({ countB: state.countB + 1 })),
  decrementB: () => set((state) => ({ countB: state.countB - 1 })),
  resetB: () => set(initialStateB)
})

export { createSliceB, initialStateB };

shared.ts

import { StateCreator } from "zustand";
import { Shared, SliceA, SliceB } from "./sliceTypes";
import { initialStateA } from "./sliceA";
import { initialStateB } from "./sliceB";

const createSharedSlice: StateCreator<
  SliceA & SliceB,
  [],
  [],
  Shared
> = (set ) => ({
  resetAll: () => set({...initialStateA, ...initialStateB})
})

export { createSharedSlice };

useSliceStore.ts

import { create } from "zustand";
import { createSliceA } from "./slices/sliceA";
import { createSliceB } from "./slices/sliceB";
import { createSharedSlice } from "./slices/shared";
import { SliceA, SliceB, Shared } from "./slices/sliceTypes";

const useSliceStore = create<SliceA & SliceB & Shared>()((...a) => ({
  ...createSliceA(...a),
  ...createSliceB(...a),
  ...createSharedSlice(...a)
}))

export { useSliceStore };

SliceA、SliceB というスライスと SliceA、SliceB をまたぐ処理を持つ Shared というスライスを useSliceStore.ts でまとめています。

表示と操作をするためにコンポーネントを1つ作ります。

./src/components/Counter.tsx

import { FC, ReactNode } from "react";

type CounterProps = {
  children?: ReactNode,
  count: number,
  onIncrement: () => void,
  onDecrement: () => void,
  onReset: () => void,
  onResetAll: () => void,
}

const Counter: FC<CounterProps> = ({children, count, onIncrement, onDecrement, onReset, onResetAll}) => {
  return (
    <>
      <h2>{children}</h2>
      <p>{count}</p>
      <button onClick={onIncrement}>+</button>
      <button onClick={onDecrement}>-</button>
      <button onClick={onReset}>reset</button>
      <button onClick={onResetAll}>reset all</button>
    </>
  )
}

export { Counter };

ただ操作と表示をするだけのものなので特筆するところはないです。 最後に App.tsx に配置します。

App.tsx

import "./App.css";
import { Counter } from "./components/Counter";
import { CountNumber } from "./components/CountNumber";
import { IncrementCountButton } from "./components/IncrementCountButton";
import { DecrementCountButton } from "./components/DecrementCountButton";
import { ResetCountButton } from "./components/ResetCountButton";
import { useSliceStore } from "./stores/useSliceStore";

function App() {
  const countA = useSliceStore((state) => state.countA);
  const countB = useSliceStore((state) => state.countB);
  
  const {
    incrementA,
    incrementB,
    decrementA,
    decrementB,
    resetA,
    resetB,
    resetAll
  } = useSliceStore((state) => state);
  
  return (
    <>
      <div className="card">
        <CountNumber/>
        <IncrementCountButton/>
        <DecrementCountButton/>
        <ResetCountButton/>
      </div>
      <div className="card">

      </div>
      <div className="card">
        <Counter
          count={countA}
          onIncrement={incrementA}
          onDecrement={decrementA}
          onReset={resetA}
          onResetAll={resetAll}
        >SliceA</Counter>
      </div>
      <div className="card">
        <Counter
          count={countB}
          onIncrement={incrementB}
          onDecrement={decrementB}
          onReset={resetB}
          onResetAll={resetAll}
        >SliceB</Counter>
      </div>
    </>
  )
}

export default App

これでスライスパターンを用いたストアの作成ができました。

注意点

使用にあたって気をつけたほうが良いと思われる点が幾つかありましたので軽く触れておきたいと思ます。

ネストしたオブジェクトの取り扱い

公式ドキュメントの Deeply nested object の項に記載がありますが、ネストしたオブジェクトの更新には一手間必要です。ドキュメントのコードを流用します。

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

このような state を更新する場合、React や Redux 同様にスプレッド展開を使用して変更する必要があります。

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

この書き方でも動きますが、直感的ではないことに加えミスにつながる可能性もあるため、公式ドキュメントにもあるように Immeroptics-tsRamda などのライブラリを併用する必要がありそうです。

optics-ts を使う場合はこのように書くことができ、記述が短くなります。

opticsInc: () =>
    set(O.modify(O.optic<State>().path("deep.nested.obj.count"))((c) => c + 1)),

Immer もよく使われるイメージですが、使用する場合は注意点があるようなので確認してから使うと良いでしょう。

不要な再レンダリング

Zustand ではストアが更新されると値を購読しているコンポーネントは再レンダリングされます。 値が更新されたかどうかは Object.is() を使用して判定されています。

Object.is() は配列やオブジェクトの等価性を比較する際に参照が変わったかどうかで等価かどうかを判定します。つまりオブジェクトや配列の一部を更新した場合でも、参照を変更しないかぎり更新処理は走りません。

一方、コンポーネント側で購読している値によっては不要なレンダリングが発生する可能性があるため、useShallow2 や useMemo を使用してレンダリングの最適化をする必要があります。

まとめ

記述量も少なく、hook のような使い心地で状態を管理できる点はとても良いライブラリだと感じました。 今回記述したような基本的な使い方以外にもサードパーティのライブラリがあること、React だけではなく Vanilla JS / Vue / Angular でも使用できる点も汎用的でこのライブラリのメリットと感じています。

今回作ったコードはリポジトリにおいてありますので気になる方はご確認ください。


1

JavaScript Rising Stars はその1年間で Github star の増加数が多いライブラリをまとめる取り組み

2

useShallowZustand が提供している hook です