🔍

Reactのグローバルな状態管理手法を比較する

2024/05/29に公開

Webアプリ開発歴1か月の超初心者です.Reactで小さなサンプルコードを書きながら,Webアプリの作り方を少しずつ学んでいます.

今回はReactアプリでグローバルな状態を管理する手法について比較してみました.

グローバルな状態管理

Reactではコンポーネントの状態を管理する方法としてuseStateなどのフックが用意されています.子コンポーネントで親コンポーネントが管理している状態を参照・編集するときは,その状態を子コンポーネントのpropsとして渡します.しかしアプリケーションのコンポーネントツリーが深くなると,props渡しの連続,いわゆるバケツリレーが起きてしまいます.そこで親(またはアプリ全体)の状態をコンポーネントの外のグローバル領域で管理し,直接そちらを参照するすることでバケツリレーを回避します.これを実現するのがReact HooksのuseContextやReduxなどのライブラリです.

グローバルな状態を扱う手法はいくつもありますが,今回は比較的多く使われているReact HooksのContext, Redux (Redux Toolkit), Zustand, Jotaiで書き方を比較してみました.npm trendsによると,最近はRedux ToolkitよりもZustandの方が使われているようですね.

https://npmtrends.com/@reduxjs/toolkit-vs-jotai-vs-react-redux-vs-zustand

サンプルアプリ

サンプルアプリとして簡単なTodoリストを作成しました.このアプリは以下の要件を満たすように実装します.

  • 未完了のタスクをリスト形式で表示する.
  • リストの項目のチェックボックスにチェックを付けると,紐づくタスクをリストから削除する.
  • リストの項目をダブルクリックすると,紐づくタスクの名前をフォームで編集できるようになる.
  • リストに新規タスクを追加するための入力フォームとSubmitボタンを用意する.

アプリ内のコンポーネントの階層構造は以下の通りです.

- EntryForm
  - Entry Textbox
  - Submit Button
- Todo List
  - Todo Item
    - (Checkbox + Task Label) or Editor Textbox

そしてプロパティにidtextを持つTodoオブジェクトのリストをアプリのグローバルな状態として保持します.

import { nanoid } from "nanoid";

type Id = string & { Id: never };
const newId = () => nanoid() as Id;

type Todo = {
  id: Id;
  text: string;
};

// グローバルの状態はTodo[].

Todo[]に対してはタスクの追加・編集・削除処理を実装します.

各手法で書き方にどのような差があるか簡単に確認するため,小規模なプロジェクト構成にしました.実際のところ,この程度ならグローバルな状態管理手法を使わなくても問題ないと思います.

Context (React Hooks)

Reactに元から備わっているReact HooksのContextを使用した方法です.ただし状態更新をイミュータブルに行うため,use-immerを使用しました.

アクションの定義とそれを扱うリデューサー,そしてアクションクリエイターをreducer.tsで定義しています.Provider.tsxではこのリデューサーとアプリの状態(ストア)の初期値をもとにcreateContextでコンテキストを作成しています.このファイルには作成したコンテキストに紐づくTodosProviderコンポーネントも一緒に定義しています.

TodosProviderの内部では,useImmerReducerの戻り値であるTodoリストの状態を保持する変数 (todosState) とディスパッチャー (dispatch) のそれぞれに対応するプロバイダーを保持しています.これはコンテクストの変更によるコンポーネントの不要な再描画を抑制するためです.

内部でコンテクストを参照しているコンポーネントは,そのコンテクストが変更されたときに再描画されます.ここでuseImmerReducerの戻り値をそのままコンテクストとして保持してしまうと,コンテクストが変更されたときに,コンテクストを参照せずディスパッチャーを使用しているだけのコンポーネントも再描画されてしまいます.これを避けるために,状態を参照する変数とディスパッチャーでコンテクストを分けて管理します.

function TodosProvider({
  children,
}: { children: React.ReactNode }): React.JSX.Element {
  const [todosState, dispatch] = useImmerReducer(reducer, initialTodos);

  return (
    <TodosContext.Provider value={todosState}>
      <TodosDispatchContext.Provider value={dispatch}>
        {children}
      </TodosDispatchContext.Provider>
    </TodosContext.Provider>
  );
}

なおここで定義しているTodosProviderは,コンポーネント中でストアから状態を取り出すために必要なコンポーネントです.TodosProviderの子孫コンポーネントのみがコンテクストを参照できます.このアプリではApp.tsxでEntryFormコンポーネントとTodoListコンポーネント親として使用しています.

function App() {
  return (
    <>
      <h1>Todo List</h1>
      <TodosProvider>
        <EntryForm />
        <TodoList />
      </TodosProvider>
    </>
  );
}

コンテクストはuseContextで値を取り出せますが,不用意にコンテクストを編集されるのを防ぐため,hooks.ts内でコンテクスト専用のカスタムフックを定義しました.コンポーネントではカスタムフックを通して状態を取得します.また状態の変更はカスタムフックから取得したディスパッチャーとアクションクリエイターを用いて行います.

Redux (Redux Toolkit)

グローバル状態管理ツールの代表格です.現在は素のReduxではなくRedux Toolkitを用いることが推奨されています[1]

Reduxではアプリ全体の状態(ステート)をグローバルなストアで保持します.イベント発火(ディスパッチ)によってイベントに関連するデータのオブジェクト(アクション)を純粋関数であるリデューサーが処理し,ストアを更新していくことで状態の変化を扱います.このアーキテクチャではMVCやMVVMとは異なり,イベントの発火から状態の更新までデータが一方向にのみ流れます.

モデルとなったFluxではアプリ中にストアを複数保持できましたが,Reduxでは一つだけです.トップレベルにある一つの状態を個々のコンポーネントが参照していくトップダウン型の状態管理手法です.

slice.tsではcreateSliceによりリデューサーとアクションクリエイターを同時に作成します.通常リデューサーでは関数内で引数に受け取ったアクションの種別によって処理を切り替える必要があります.ですがcreateSliceではreducersプロパティのオブジェクトにアクションに対する処理を個別のメソッドとして実装します.処理を別の関数として定義できるのは嬉しいですね.

store.tsでリデューサーからストアを作成し,hooks.tsでストアからTodoリストに関するステートのみを取り出すセレクターのカスタムフックを定義しています.

コンポーネント内での状態の取り出し方や状態の更新方法はContextの時とほぼ同じですね.

なお,Reduxでもストアを参照するコンポーネントは祖先にProviderを持つ必要があります.子のアプリではContextの例と同じくApp.tsx内で定義しています.

Zustand

Reduxではストア,アクション,ディスパッチャー,リデューサーと分かれていましたが,これらを区別せずより簡単に書けるようにしたのがZustandです.

Zustandではストアに対して,Reduxにおけるステートとリデューサーを同じオブジェクト内で定義します.またリデューサーにあたる部分もストアオブジェクトのメソッドのように処理ごとに定義します.

store.tsではストアを定義しています.ここでストアにはTodoリストの状態 (todos), タスクの追加処理関数 (addTodo), タスクの編集処理関数 (modifyTodo), タスクの削除処理関数 (removeTodo) を一つのオブジェクトで定義します.hooks.tsでそれぞれをストアから取得するカスタムフックを作成し,各コンポーネントではカスタムフックから状態や関数を取得します.

コンポーネント内での状態の取得方法はReduxと同じですが,状態の更新はカスタムフックで取得した関数をそのまま実行する形になるので,より直感的な書き方になります.

またContextやReduxとは異なり,ZustandではProviderが無くてもコンポーネント内部からグローバルストアを参照することが可能です.ここまで簡略化されているんですね.

Jotai

Jotaiは先に挙げた3つの方法とは異なりボトムアップ型の状態管理を行います.アプリ内で存在するデータを個々のアトムとして定義し,それらの依存関係などから上位のアトム(派生アトム)を作成します.また派生アトム間の関係からさらに上位の派生アトムを作成していき,アプリ全体の状態を表現します.

atoms.tsにTodoリストのアトム (todosAtom) と,splitAtomでリストの要素ごとにアトムをラップした派生アトム (todoAtomsAtom), タスクの追加を行う派生アトム (addTodoAtom), タスクの削除を行うアトム (removeTodoAtom) を定義しています.

コンポーネント内ではuseAtom/useAtomValue/useSetAtomを使用してアトムの値や変更関数を取得します.このあたりはZustandと同じです.

またTodoList.tsxではTodoListItemコンポーネントにtodoAtomsAtomの要素をpropsとして渡しています.要素ごとにアトム化することで,TodosListItemの内部でタスクの編集(propsで渡したアトムの更新)を行った際に,コンポーネントの再描画がそのアトムを参照しているもの,つまり編集したTodoListItemのみ実行され,関係のないコンポーネントの再描画を抑制します.

JotaiではProviderの子孫コンポーネント内で参照されたアトムは,Providerのコンテクストにおける状態を保持します.今回はTodoListItem内のローカルな状態もアトムで管理したため,TodoListの内部でTodoListItemを囲むようにProviderを設定しています.なおremoveTodoはグローバルな状態を操作するアトムのため,Providerによる参照制限を回避する必要があります.そのためProviderの外でuseSetAtomを用いてタスクの削除処理関数を取得しTodoListItemに渡しています.

const removeTodo = useSetAtom(removeTodoAtom);
const remove = (todoAtom: PrimitiveAtom<Todo>) => removeTodo(todoAtom);

return (
  <ul>
    {todoAtoms.map((todoAtom) => (
      <Provider key={`${todoAtom}`}>
        <TodoListItem todoAtom={todoAtom} removeTodo={remove} />
      </Provider>
    ))}
  </ul>
);

感想

かつてC++でReduxアーキテクチャーを実装したことがあったので,ContextとReduxはすんなりと書けました.ContextよりもRedux Toolkitを用いたほうがリデューサーを処理別にメソッドで記述できたり,アクションクリエイターを自動で作成してくれたりして便利でした.

ZustandはContextやReduxの考えを踏襲しつつ,アクションクリエイターやディスパッチャーの呼び出しが無かったり,プロバイダーが不要だったりと色々楽に書けたのが好印象でした.今後はZustandを採用しようかなと思います.

Jotaiは他のものと毛色が違いボトムアップ型の状態管理だったため,Reduxに慣れている人からすると少し慣れが必要かもしれないと感じました.考え方は面白いので小さなアプリケーションを作るときに試してみるのもアリかなと思います.


脚注
  1. Reduxのイントロダクション記事より.

    Redux Toolkit is our official recommended approach for writing Redux logic.

    ↩︎

Discussion