Flutter HooksのuseReducerで複雑な状態管理を行う

2024/02/21に公開

Flutter Hooks のuseReducerについて解説します。これは状態管理を行う機能で、useStateよりも複雑な状態ロジックを扱うことが可能です。

useReducer の役割と重要性

useReducerは状態更新ロジックを一箇所にまとめ、アクションによって駆動される状態の変更を行います。そのため、状態の予測可能性が向上し、単純な useStateに比べて以下のような利点があります。

  1. 状態更新ロジックを一箇所にまとめることができるので、コードの可読性と保守性が向上します。
  2. アクションによって状態の変更が駆動されるため、状態の変更が予測可能になります。
  3. 状態更新ロジックをコンポーネントから分離することで、テストと再利用が容易になります。

このような特性から、useReducerは大規模なプロジェクトや複雑な状態ロジックを扱う場面で特に有用です。

useReducer の基本的な知識

useReducerは現状の状態 (state) とアクション (action) を引数に取り、新しい状態 (newState) を返す関数(reducer)を利用します。

reducer 関数は以下の形式を持ちます。

(state, action) => newState
  • state: 現在の状態
  • action: 状態を変更するためのアクション
  • newState: 新しい状態

そして、useReducerフック自身は、この reducer 関数と、初期状態、初期アクションを引数に取り、現在の状態とその状態を変更するためのディスパッチ関数を持つ store を返します。

final store = useReducer(reducer, initialState, initialAction);

useReducer の利用法

useReducerの利用方法はシンプルです。まず、reducer 関数と初期状態を定義します。

typedef MyAction = ({String type, int payload});

int reducer(int state, MyAction action) {
  return switch (action.type) {
    'add' => state + action.payload
    'subtract' => state - action.payload
    _ => state
  };
}

その後、useReducerフックを呼び出し、reducer 関数と初期状態を引数に渡します。

final store = useReducer(reducer, initialState: 0, initialAction: {type: 'initial', payload: 0});

これで、現在の状態とその状態を変更するためのディスパッチ関数を持つ store が得られます。

アクションの型の比較

アクションの定義の部分においては型の選択が重要になります。それぞれの型には以下のような利点と欠点があります。

個々のプロジェクトの要件によっては、これらの型の一つや、もしくはその他のカスタム型を使用してアクションを定義することが可能です。

String

利点:

  • シンプルで直感的: 文字列はシンプルで理解しやすい。
  • デバッグが容易: 文字列はデバッグ時にログで簡単に確認できるため、どのアクションがディスパッチされたかを追跡しやすい。

欠点:

  • ペイロードを持つことができない: 文字列だけでは、アクションに追加のデータ(ペイロード)を含めることができないので、アクションが単純な操作に限られる。
  • タイプミスによるバグが発生しやすい: 文字列リテラルを手動で入力すると、タイプミスが発生しやすく、これが原因でバグが発生する可能性がある。
  • 自動補完が効かない: 開発環境では、文字列リテラルに対する自動補完機能が限られているため、タイピングの手間が増える。

Enum

利点:

  • タイプミスによるバグが発生しにくい: Enum は利用可能な値が事前に定義されているため、タイプミスによるエラーを防ぐことができる。
  • 自動補完が効く: 開発環境で Enum を使用すると、利用可能な値に対して自動補完機能が働くため、開発の効率が向上する。

欠点:

  • ペイロードを持つことができない: Enum 自体はペイロードを持つことができない。

Record

利点:

  • ペイロードを持つことができる: アクションに追加のデータを含めることができる。より複雑な操作を表現することが可能になります。
  • 構造がわかりやすい: アクションの構造を明確にすることができる。コードの可読性が向上する。

欠点:

  • 定義が複雑になる可能性がある: アクションの定義が複雑になる可能性がある。

sealed class

利点:

  • タイプセーフ: コンパイル時に型安全性を保証できる。不正な値がアクションとして渡されることを防ぐことができる。
  • ペイロードを持つことができる: 各sealed classのインスタンスは、異なるタイプのペイロードを持つことができる。
  • 網羅性チェックが可能: アクションの処理時にすべてのケースが考慮されているかどうかをチェックできる。

欠点:

  • 定義が複雑になる可能性がある: アクションごとにクラスを定義する必要がありり、特に多くの異なるアクションを扱う場合に、定義が複雑になりがち。

useState との比較

useState と useReducer はどちらも状態管理のためのフックですが、それぞれが最適となる状況は異なります。

useState はシンプルな状態管理に適しています。例えば、ボタンのクリック数を追跡するなど、状態が単一の値で表現でき、その更新ロジックが単純な場合には useState が適しています。

final counter = useState(0);

void _incrementCounter() {
  counter.value = counter.value + 1;
}

一方、useReducer はより複雑な状態管理に適しています。例えば、複数の関連する値を持つ状態や、その更新ロジックが複雑な場合には useReducer が適しています。

useReducer を使用することでコードの可読性と保守性を向上させることができます。以下のような状況では、useState よりも useReducer の方が適しています。

  1. 複数のフィールドを持つクラスや配列のような複雑な状態の場合: useReducer を使用すると、複数の値を一元的に管理することができる。
  2. 状態の更新ロジックが複雑で、前の状態に基づいて次の状態を計算する必要がある場合: useReducer は、状態の更新ロジックを一箇所にまとめ、テストと再利用を容易にする。
  3. 同じ状態更新ロジックを複数のアクションで再利用する必要がある場合: useReducer は、アクションに基づいて状態の更新を行うため、ロジックの再利用が容易。
  4. 意図しない状態の変更が行われそうな場合: useReducer はアクションに基づいて状態の更新を行うため、意図しない状態の変更を防ぐことができる。状態の更新ロジックが一箇所にまとまっているため、バグや意図しない状態の変更を見つけやすくなる。

useReducer の使用例

以下に、useReducer を使用する具体的な例を示します。

typedef ToDo = ({String id, String title});           // 一般的にはclassで作成する
typedef ToDoState = List<ToDo>;                       // typedefを利用しなくても良い
typedef ToDoAction = ({String type, ToDo? payload});  // 適切な型を利用する

ToDoState reducer(ToDoState state, ToDoAction action) {
  return switch (action) {
    (type: 'add', payload: final value?) => [...state, value],
    (type: 'remove', payload: final value?) => state.where((todo) => todo.id != value.id).toList(),
    _ => state,
  };
}

final store = useReducer<ToDoState, ToDoAction>(reducer, initialState: <ToDo>[], initialAction: (type: 'initial', payload: null));

この例では、ToDo リストの状態を管理しています。Todo アイテムの追加と削除という 2 つのアクションを扱うことができます。このような複雑な状態管理は、useReducer を使用することで簡単に行うことができます。

useReducer を使うときの注意点

useReducer を使用する際には、以下の点に注意する必要があります。

  1. reducer 関数は純粋な関数であるべき。同じ入力に対しては常に同じ出力を返し、副作用を持つべきではない。
  2. アクションは常に新しい状態を生成するべき。既存の状態を直接変更するのではなく、新しい状態オブジェクトを生成して返すようにする。
    1. 予測可能性: 新しい状態が常に新しいオブジェクトとして生成されるため、状態の変更が予測可能になる。デバッグやテストを容易にする。
    2. パフォーマンス最適化: 状態オブジェクトが新しい参照を持つときにのみ再レンダリングを行う。既存の状態を直接変更すると、新旧の状態が同じ参照を持つため、フレームワークが再レンダリングを行わない可能性がある。
    3. 状態の変更履歴の追跡: 新しい状態が新しいオブジェクトとして生成されるため、状態の変更履歴を簡単に追跡することができる。開発ツールでのデバッグや、時間旅行型のデバッグを可能にする。
  3. 複雑な状態更新ロジックは、可能な限り reducer 関数の外に抽出すると良い。テストと再利用が容易になる。
  4. reducer は async/await にすることができないので、非同期処理を状態に反映させる場合は、非同期処理の結果をアクションとしてディスパッチすると良い。Riverpod を利用している場合は AsyncValue を渡す。

まとめ

Flutter Hooks の useReducer は、複雑な状態ロジックを扱う際に強力なツールとして機能します。

useState と比較した場合、useReducer は状態更新ロジックを一箇所に集約することで、状態の変更をより予測可能にし、テストや再利用を容易にします。特に、状態が複雑で複数のフィールドを持つ場合や、状態の更新ロジックが複雑な場合、または同じ状態更新ロジックを複数のアクションで再利用する必要がある場合に useReducer の使用が推奨されます。

useReducer を使用する際には、reducer 関数が純粋であること、アクションによって常に新しい状態が生成されること、そして複雑な状態更新ロジックを可能な限り reducer 関数の外に抽出することが重要です。これにより、コードの保守性と可読性が向上し、より効率的な開発が可能になります。

合同会社CAPH TECH

Discussion