Flutter で useReducer を使ってみよう

この記事は YUMEMI Flutter Advent Calendar 2023 の 22 日目の記事です。


https://qiita.com/advent-calendar/2023/yumemi-flutter


## はじめに

Flutter エンジニアのかんたです。ある日、社内のエンジニアの方から `useReducer` を用いた状態管理方法を紹介され、興味を持ちました。私も `useReducer` を使ってみたくなり、インターネットで使い方などを調べてみましたが、Flutter における `useReducer` に関する情報はほとんど見当たりませんでした。（※ 執筆直後、`useReducer` について教えてくださったエンジニアの方が記事[^useReducer_article]を出されたので、ぜひ併せてご覧ください。より実践的な利用例を紹介してくださっています）

Flutter には、他にも状態管理方法（`Provider`、`Riverpod` など）があり、それらを使えば `useReducer` なしでもアプリ開発が可能なため、`useReducer` に関する情報が少ないのだと思います。

とはいえ、状態管理の手段を増やすことで、技術の幅を広げ、様々なシチュエーションでより適切な状態管理の選択ができるようになります。したがって、本章では Flutter における `useReducer` の使い方を学び、そのユースケースについて考えていきます。

[^useReducer_article]: https://zenn.dev/caphtech/articles/flutter-hooks-usereducer

## 対象者

- `useReducer` の使い方を知りたい方
- 状態管理のバリエーションを増やしたい方
- Flutter エンジニアの方

## useReducer とは？

`useReducer` とは、flutter_hooks[^flutter_hooks] パッケージに含まれる hooks の 1 つです。このパッケージは React hooks のコンセプトを Flutter に取り入れたもので、フロントエンジニアの方にとっては馴染み深いのではないでしょうか。

[^flutter_hooks]: https://pub.dev/packages/flutter_hooks

flutter_hooks についての公式の説明は次のとおりです。

> hooks は、Widget の life-cycle を管理する新しい種類のオブジェクトです。存在する理由は 1 つ。重複を削除することで、Widget 間のコード共有を増やすためです。

公式ドキュメント[^公式ドキュメント] によれば、`useReducer` は、より複雑な状態のための `useState` の代替と説明されています。
`useReducer` は、`State`, `Action`, `Store`, `Reducer` から構成され、これらの要素間の関係を理解することが重要です。これらの関係を表した図を次に示します。

[^公式ドキュメント]: https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useReducer.html

<div align="center">
    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/787216/3388d282-f8e1-592d-9283-e14a4f283885.png" >
</div>



次のセクションからは、カウンターアプリの作成を通じて、`useReducer` の使い方を学んでいきます。

## カウンターアプリを作る

作成するアプリの要件は次のとおりです。

- 「＋」ボタンを押すと Count を 1 増やす
- 「−」ボタンを押すと Count を 1 減らす
- 「×」ボタンを押すと Count に 2 をかける
- 「リセットアイコン」を押すと Count を 0 にリセットする

### アプリの UI

アプリの UI は次の画像のとおりです。
実際に動作するアプリをご覧いただくには、章末の GitHub リンクから clone してローカルでご確認ください。

<div align="center">
    <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/787216/bd4bbe04-5b80-2d3c-2393-e3591624af43.png" width="300" style="align: center">
</div>

### 実装

#### 1. State を定義

今回カウンターアプリで作成する `State` の実装は次のとおりです。

```dart
@immutable
class CounterState {
  const CounterState({this.count = 0});
  final int count;

  CounterState copyWith({int? count}) {
    return CounterState(count: count ?? this.count);
  }
}
```

`count` という値をプロパティとして持つ `CounterState` クラスを定義しています。
`CounterState` の値が変わったときに Widget をリビルドするためには `CounterState` を immutable なクラスで実装する必要があります。
Flutter でアプリを開発するにあたり、 immutable な `State` を作成する際は `freezed` パッケージを用いるのが一般的ですが、本章で作成するカウンターアプリの `State` はシンプルなものであるため、自前で immutable なクラスを作成しています。

`CounterState` には、`@immutable` アノテーションを付与して immutable な実装がされていない時に警告を出すようにしています。また、プロパティの一部を変更し、新しい `CounterState` インスタンスを返す `copyWith` メソッドも実装しています。

#### 2. Action を定義

次に `Action` の実装をみていきます。

```dart
sealed class CounterAction {}

class CounterIncrementAction implements CounterAction {
  const CounterIncrementAction();
}

class CounterDecrementAction implements CounterAction {
  const CounterDecrementAction();
}

class CounterMultiplyAction implements CounterAction {
  const CounterMultiplyAction();
}

class CounterResetAction implements CounterAction {
  const CounterResetAction();
}
```

`Action` の定義には、`sealed` クラスを利用しています。`selaed` クラスを用いることで、`dispatch` から渡された `Action` を `switch` 式で網羅的に条件分岐し、`Action` に対応した処理を実行できます。

ただ、本章で作成しているアプリは非常にシンプルで各 `Action` にプロパティを持たせる必要がないため、`enum` で代替可能です。
もし、`Action` にプロパティを持たせたい場合は `enum` を利用できないため、今回のように `sealed` クラスで実装する必要があります。

また、Dart3 対応ができていないプロジェクトもあるかもしれません。
そういったプロジェクトでは、`sealed` クラスの代わりに `freezed` パッケージを利用してパターンマッチングを実現できます。

#### 3. reducer を定義

`CounterState`、`CounterAction` を利用して作成した `reducer` 関数の実装は次のとおりです。

```dart
typedef CounterReducer = Reducer<CounterState, CounterAction>;
CounterReducer get reducer => (state, action) {
  return switch (action) {
    CounterIncrementAction() => state.copyWith(count: state.count + 1),
    CounterDecrementAction() => state.copyWith(count: state.count - 1),
    CounterMultiplyAction() => state.copyWith(count: state.count * 2),
    CounterResetAction() => state.copyWith(count: 0),
  };
};
```

引数に `state` と `action` を受け取り、`action` の型に応じたパターンマッチングを行っています。
そして、各パターンにおいて、 `CounterState` で定義した `copyWith` メソッドを利用し、新しい `State` を返しています。このように immutable な状態の変更をすることで、`Store` の `state` を購読している View は `state` の変更に応じてリビルドされます。

#### 4. build 内で useReducer を使う

最後に、build 内で利用する useReducer についてみていきます。

```dart
@override
Widget build(BuildContext context) {
  final CounterStore store = useReducer(
    reducer,
    initialState: const CounterState(),
    initialAction: const CounterResetAction(),
  );
  // 省略
}
```

`useReducer` の引数にこれまで作成した `reducer`, `CounterState`, `CounterAction` を渡します。
また、`store` の型である `CounterStore` は次のように `typedef` で定義しています。

```dart
typedef CounterStore = Store<CounterState, CounterAction>;
```

ここで 1 点注意が必要です。今回のように `store` に型を定義するか、`useReducer` に型を定義しないと `sealed` クラスを利用できません。（`Action` の型が `CounterAction` ではなく、`initialAction` に指定している型しか利用できなくなるため）

次に、`useReducer` から返された `store` の利用方法をみていきます。
`CounterState` の値には、`store.state.[CounterStateのプロパティ名]` でアクセスできます。
そして、`CounterState` の値は `reducer` 関数から新しい `State` が返されるごとに変更され、 Widget がリビルドされます。

```dart
Text(
  'Count: ${store.state.count}',
  style: const TextStyle(fontSize: 18),
),
```

次に、`store.dispatch()` をみていきます。
`dispatch` の引数に `Action` を渡して実行することで、`reducer` 関数では `Action` の種類に応じた処理が実行されます。

```dart
IconButton(
  onPressed: () => store.dispatch(const CounterIncrementAction()),
  icon: const Icon(Icons.add),
),
```

上記の `CounterIncrementAction` 以外の `Action` を渡す `dispatch` を実装することで、要件を満たすアプリを作成できます！

### useReducer を使うメリット

以上で `useReducer` の使い方を見ていきました。
`useReducer` に必要な要素を分解し、一つ一つの実装を見ることで基本的な使い方はなんとなく理解できたのではないでしょうか？
この理解をベースに、`useReducer` を利用するメリットをみていきます。

- `useReducer`を使うメリット
  - ロジックを Widget から剥がすことができて Widget の肥大化を抑えられる
  - Action 毎のロジックが `reducer` にまとまって定義されており、認知負荷が低い
  - `reducer` は Widget に依存しない純粋関数であるためテストがしやすい

若干学習コストはかかると思いますが、様々なメリットがあり、利用する価値はありそうです。

次のセクションでは `useReducer` のユースケースを考えていきます！

## ユースケース
Flutter における `useReducer` のユースケースは次の 2 つが考えられます。
1. `useState` の数が多く、`state` 更新ロジックの複雑さから Widget が肥大化してきた時
	- `useReducer` により、複数の `state` を `store` で一元管理し、ロジックを `reducer` に集約することで、 Widget の肥大化を抑え、可読性を向上させることができる
2. `useTextEditingController`、`usePageController` などのロジックが複雑になる時
	- これらのライフサイクルに関連する状態を `provider` で管理する場合、Widget と Provider のライフサイクルを同時に考慮する必要があるため

Flutter には `Riverpod` という強力な状態管理パッケージが存在します。
複数の Widget に跨ぐ状態に関しては、`Riverpod` で管理するのが一般的です。
一方、単一の Widget でしか利用しない状態に関しては `flutter_hooks` を利用しているプロジェクトもあるでしょう。このようなプロジェクトでは上記のユースケースで `useReducer` の利用が考えられます。

## まとめ
本章では、カウンターアプリの作成を通じて `useReducer` の基本的な使い方を紹介しました。
そのメリットやユースケースについて考え、どういった場面で有効かを検討しました。また、今回はユースケースに沿ったサンプルアプリを紹介することはできなかったため、今後記事などでアウトプットできればと思います！

### サンプルコード
今回記事で紹介したサンプルコードを GitHub に置いています。興味があれば是非ご覧ください。

https://github.com/morikann/reducer_sample