EY-Office ブログ

React用ステート管理ライブラリーzustandは良さそうですね

以前書いたReact用ステート管理2020 〜Recoilを試してみました〜の時点で、主なReact用ステート管理方法には

などがありました、元祖Redux以外は現在でも使われていると思います。最近になってZustandというステート管理ライブラリーが話題になっているので調べてみました。

結論としては、とても良さそうです(アイコンの熊さんも可愛いし😊)。

未来 Illustrations @ Tina Henschel

ステート管理ライブラリーは2つのタイプがある

ステート管理ライブラリーは2つのタイプがあるように思えます。

① 手軽に使える

UIコンポーネントの中で気軽にステート管理が行えるライブラリー。これに該当するのは以下でしょうか、

② 体系的な管理に向く

ほとんどのステート管理ライブラリーは、こちらだと思います。手軽に使えるライブラリーで気軽にコードを作って行くと、メンテナンス性の低いコードになってしまいいます。
また、ステート管理がUIコンポーネント内にあるとコンポーネントの再利用性もさがるので、UIとステート管理・ロジックを分けるのは中規模以上のアプリでは自然な考えだと思います。

Redux Toolkitは元祖Reduxの進化系ですし、useReducer + ContextもFlux(Redux)アーキテクチャの実装です。 ただし、Xstateは有限状態マシンをベースにしています→参照記事、またMobxはオブザーバー・パターンをベースにしています→参照記事

zustandとは

まずzustandは、状態のドイツ語です。 zustandのドキュメントは以下のよう書かれています。

  • Redux よりも優れている点
    • シンプルで独善的ではない
    • Hooksを使い倒す
    • アプリをProviderコンポーネントで括る必要がない
    • 再レンダー以外にステートの更新を伝えるsubscribe関数がある
  • useReducer + Context よりも優れている点
    • 冗長な定型コード(boilerplate)が少ない
    • 不要な再描画が起きない
    • 集中型のアクションベースのステート管理

まとめると、よりシンプルなRedux Toolkitです。

Redux Toolkit vs. zustand

という事で、同じアプリをzustandとRedux Toolkitで書いて比べてみましょう。いつものジャンケンアプリです。😊

アプリの画面イメージ

1. store部分

まずは、Reduxの核であるStore部分のコードです。

  • ① ⑪ Redux toolkitではステートの型にはステート値のみですが、zustandではActionも型に含まれます
  • ② ⑫ Redux toolkitのcreateSliceがzustandではcreateになります
    • zustandではmiddlewareが関数としてcreateにネストします
    • immer(Immer)は破壊的なステート更新できるライブラリーで、Redux toolkitではデフォルトで入っています
    • devtoolsはRedux DevToolsを使うためのmiddlewareです
  • ③ ⑬ Redux toolkitのponアクションはReduxアクション用の引数が渡ってきますが、zustandは普通の関数です
  • ④ ⑭ステートの更新、zustandではcreate(immer)の引数で渡ってくるset関数を使います
  • ⑮ immerを使わない非破壊的ステート更新の場合は、このようなコードになります
  • ⑤ Redux toolkitではアクションやリデューサーのexport用のコードが必要です
Redux Toolkit
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

export enum Jjudgment {
  Draw = 0,
  Win,
  Lose
}
export enum Te {
  Guu = 0,
  Choki,
  Paa
}

export type ScoreType = {
  human: number;
  computer: number;
  judgment: Jjudgment;
};

export type PonPayload = Te;
export type ScoreState = ScoreType[];   // ← ①

let initialState: ScoreState = [];

const scoreSlice = createSlice({        // ← ②
  name: "score",
  initialState,
  reducers: {
    pon(state: ScoreState, action: PayloadAction<PonPayload>) {  // ← ③
      const human = action.payload;
      const computer: Te = Math.floor(Math.random() * 3);
      const judgment: Jjudgment = (computer - human + 3) % 3;
      const score = { human: human, computer: computer, judgment: judgment };
      state.push(score);                // ← ④
    }
  }
});

export const { pon } = scoreSlice.actions;    // ← ⑤
export default scoreSlice.reducer;            // ← ⑤
zustand
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { devtools } from "zustand/middleware";

export enum Jjudgment { Draw = 0, Win, Lose }
export enum Te { Guu = 0, Choki, Paa}

export type ScoreType = {
  human: number,
  computer: number,
  judgment: Jjudgment
}

export type ScoreState = {         // ← ⑪
  scores: ScoreType[];
  pon: (human: Te) => void;
}

export const useStore = create(    // ← ⑫
  devtools(
    immer<ScoreState>((set) => ({
      scores: [],
      pon: (human: Te) => {         // ← ⑬
        const computer:Te = Math.floor(Math.random() * 3);
        const judgment:Jjudgment = (computer - human + 3) % 3;
        const score = {human: human, computer: computer, judgment: judgment};
        set(state => { state.scores.push(score); });            // ← ⑭
        // set(state => ({scores: [...state.scores, score]}));  // ← ⑮
        }
    }))
  )
);

2. ステートの参照

ステートの参照を行うコンポーネントです。

  • ① ⑪ ステートの参照は、ほぼ同じでRedux toolkitではuseSelector、zustandではuseStoreを使います
Redux Toolkit
import React from "react";
import { useSelector } from "react-redux";
import { ScoreState } from "./scoreSlice";

const ScoreBox: React.FC = () => {
  const teString = ["グー", "チョキ", "パー"];
  const judgmentString = ["引き分け", "勝ち", "負け"];

  const scores = useSelector((state: ScoreState) => state);   // ← ①

  const tableStyle: React.CSSProperties = { marginTop: 20, borderCollapse: "collapse" };
  const thStyle: React.CSSProperties = { border: "solid 1px #888", padding: "3px 15px" };
  const tdStyle: React.CSSProperties = { border: "solid 1px #888", padding: "3px 15px", textAlign: "center" };
  return (
    <table style={tableStyle}>
      <thead>
        <tr>
          <th style={thStyle}>あなた</th>
          <th style={thStyle}>コンピュター</th>
          <th style={thStyle}>勝敗</th>
        </tr>
      </thead>
      <tbody>
        {scores.map((score, ix) => (
          <tr key={ix}>
            <td style={tdStyle}>{teString[score.human]}</td>
            <td style={tdStyle}>{teString[score.computer]}</td>
            <td style={tdStyle}>{judgmentString[score.judgment]}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
};

export default ScoreBox;
zustand
import React from 'react';
import { useStore } from './store';

const ScoreBox: React.FC  = () => {
  const teString = ["グー","チョキ", "パー"];
  const judgmentString = ["引き分け","勝ち", "負け"];

  const scores = useStore(state => state.scores);    // ← ⑪

  ・・・以下はRedux toolkitと同じ・・・

3. アクション

ステート更新を行う、アクションを呼び出すコンポーネントです。

  • ① ⑪ Redux toolkitではuseDispatchを使ってアクションを呼び出します。zustandではuseStoreでステートからpon関数を取り出し使います
  • ② ⑫ Redux toolkitではuseDispatchを使ってアクションを呼び出しますが、zustandでは普通の関数呼び出しです
Redux Toolkit
import React from "react";
import { useDispatch } from "react-redux";
import { pon, Te } from "./scoreSlice";

const JyankenBox: React.FC = () => {
  const divStyle: React.CSSProperties = { margin: "0 20px" };
  const buttonStyle: React.CSSProperties = { margin: "0 10px", padding: "3px 10px", fontSize: 14 };

  const dispatch = useDispatch();   // ← ①
  return (
    <div style={divStyle}>
      <button onClick={() => dispatch(pon(Te.Guu))} style={buttonStyle}>  // ← ②
        グー
      </button>
      <button onClick={() => dispatch(pon(Te.Choki))} style={buttonStyle}>
        チョキ
      </button>
      <button onClick={() => dispatch(pon(Te.Paa))} style={buttonStyle}>
        パー
      </button>
    </div>
  );
};

export default JyankenBox;
zustand
import React from 'react';
import { Te, useStore } from './store';

const JyankenBox: React.FC = () => {
  const divStyle: React.CSSProperties = {margin: "0 20px"};
  const buttonStyle: React.CSSProperties = {margin: "0 10px", padding: "3px 10px",
    fontSize: 14};

  const pon = useStore(state => state.pon);   // ← ⑪
  return (
    <div style={divStyle}>
      <button onClick={() => pon(Te.Guu)} style={buttonStyle}>グー</button>  // ← ⑫
      <button onClick={() => pon(Te.Choki)} style={buttonStyle}>チョキ</button>
      <button onClick={() => pon(Te.Paa)} style={buttonStyle}>パー</button>
    </div>
  )
}

export default JyankenBox

4. アプリのメインコンポーネント

アプリのメインコンポーネントJyanken.tsxは全く同じです。

Redux Toolkit & zustand
import JyankenBox from "./JyankenBox";
import ScoreBox from "./ScoreBox";

const Jyanken: React.FC = () => {
  return (
    <>
      <h1>じゃんけん ポン!</h1>
      <JyankenBox />
      <ScoreBox />
    </>
  );
};
export default Jyanken;

5. エントリーポイント

Reactライブラリーから最初に呼び出されるコード。今回は開発環境にViteを使ったのでmain.tsxです。

  • ① Redux toolkitではconfigureStoreを使ってストアーを作っていますが、zustandにはありません
  • ② Redux toolkitを使うにはアプリを<Provider>コンポーネントで括る必要がありますが、zustandにはありません
Redux Toolkit
import React from "react";
import ReactDOM from "react-dom/client";
import Jyanken from "./Jyanken";
import logger from "redux-logger";
import { configureStore } from "@reduxjs/toolkit";
import { Provider } from "react-redux";
import scoreSlice from "./scoreSlice";

const store = configureStore({   // ← ①
  reducer: scoreSlice,
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger)
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <Provider store={store}>     // ← ②
      <Jyanken />
    </Provider>
  </React.StrictMode>
);
zustand
import React from "react";
import ReactDOM from "react-dom/client";
import Jyanken from "./Jyanken";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <Jyanken />
  </React.StrictMode>
);

まとめ

Zustandは、とてもシンプルなReduxだとわかってって頂けましたでしょうか。

Redux Toolkitの導入には、まだRedux的な難しさがあると思います。
対してZustandは不思議なコードが少なく、わかりやすいReduxだと思います。Reduxの良さを低いコストで導入できる良いステート管理ライブラリーだと思います。 今回は出てきませんでしたが、API通信などの非同期アクションも通常の関数として書けます。

また、ドキュメントもZustand Documentation - Pmndrs.docsに良く書かれていると思います。迷ったときはRecipesを見ると解決するかもしれません。

Redux的なものを導入したくなった時に、Zustandは良い選択肢だと思います。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ