この記事は YUMEMI Flutter Advent Calendar 2023 の 22 日目の記事です。
はじめに
Flutter エンジニアのかんたです。ある日、社内のエンジニアの方から useReducer を用いた状態管理方法を紹介され、興味を持ちました。私も useReducer を使ってみたくなり、インターネットで使い方などを調べてみましたが、Flutter における useReducer に関する情報はほとんど見当たりませんでした。(※ 執筆直後、useReducer について教えてくださったエンジニアの方が記事1を出されたので、ぜひ併せてご覧ください。より実践的な利用例を紹介してくださっています)
Flutter には、他にも状態管理方法(Provider、Riverpod など)があり、それらを使えば useReducer なしでもアプリ開発が可能なため、useReducer に関する情報が少ないのだと思います。
とはいえ、状態管理の手段を増やすことで、技術の幅を広げ、様々なシチュエーションでより適切な状態管理の選択ができるようになります。したがって、本章では Flutter における useReducer の使い方を学び、そのユースケースについて考えていきます。
対象者
-
useReducerの使い方を知りたい方 - 状態管理のバリエーションを増やしたい方
- Flutter エンジニアの方
useReducer とは?
useReducer とは、flutter_hooks2 パッケージに含まれる hooks の 1 つです。このパッケージは React hooks のコンセプトを Flutter に取り入れたもので、フロントエンジニアの方にとっては馴染み深いのではないでしょうか。
flutter_hooks についての公式の説明は次のとおりです。
hooks は、Widget の life-cycle を管理する新しい種類のオブジェクトです。存在する理由は 1 つ。重複を削除することで、Widget 間のコード共有を増やすためです。
公式ドキュメント3 によれば、useReducer は、より複雑な状態のための useState の代替と説明されています。
useReducer は、State, Action, Store, Reducer から構成され、これらの要素間の関係を理解することが重要です。これらの関係を表した図を次に示します。
次のセクションからは、カウンターアプリの作成を通じて、useReducer の使い方を学んでいきます。
カウンターアプリを作る
作成するアプリの要件は次のとおりです。
- 「+」ボタンを押すと Count を 1 増やす
- 「−」ボタンを押すと Count を 1 減らす
- 「×」ボタンを押すと Count に 2 をかける
- 「リセットアイコン」を押すと Count を 0 にリセットする
アプリの UI
アプリの UI は次の画像のとおりです。
実際に動作するアプリをご覧いただくには、章末の GitHub リンクから clone してローカルでご確認ください。
実装
1. State を定義
今回カウンターアプリで作成する State の実装は次のとおりです。
@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 の実装をみていきます。
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 関数の実装は次のとおりです。
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 についてみていきます。
@override
Widget build(BuildContext context) {
final CounterStore store = useReducer(
reducer,
initialState: const CounterState(),
initialAction: const CounterResetAction(),
);
// 省略
}
useReducer の引数にこれまで作成した reducer, CounterState, CounterAction を渡します。
また、store の型である CounterStore は次のように typedef で定義しています。
typedef CounterStore = Store<CounterState, CounterAction>;
ここで 1 点注意が必要です。今回のように store に型を定義するか、useReducer に型を定義しないと sealed クラスを利用できません。(Action の型が CounterAction ではなく、initialAction に指定している型しか利用できなくなるため)
次に、useReducer から返された store の利用方法をみていきます。
CounterState の値には、store.state.[CounterStateのプロパティ名] でアクセスできます。
そして、CounterState の値は reducer 関数から新しい State が返されるごとに変更され、 Widget がリビルドされます。
Text(
'Count: ${store.state.count}',
style: const TextStyle(fontSize: 18),
),
次に、store.dispatch() をみていきます。
dispatch の引数に Action を渡して実行することで、reducer 関数では Action の種類に応じた処理が実行されます。
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 つが考えられます。
-
useStateの数が多く、state更新ロジックの複雑さから Widget が肥大化してきた時-
useReducerにより、複数のstateをstoreで一元管理し、ロジックをreducerに集約することで、 Widget の肥大化を抑え、可読性を向上させることができる
-
-
useTextEditingController、usePageControllerなどのロジックが複雑になる時- これらのライフサイクルに関連する状態を
providerで管理する場合、Widget と Provider のライフサイクルを同時に考慮する必要があるため
- これらのライフサイクルに関連する状態を
Flutter には Riverpod という強力な状態管理パッケージが存在します。
複数の Widget に跨ぐ状態に関しては、Riverpod で管理するのが一般的です。
一方、単一の Widget でしか利用しない状態に関しては flutter_hooks を利用しているプロジェクトもあるでしょう。このようなプロジェクトでは上記のユースケースで useReducer の利用が考えられます。
まとめ
本章では、カウンターアプリの作成を通じて useReducer の基本的な使い方を紹介しました。
そのメリットやユースケースについて考え、どういった場面で有効かを検討しました。また、今回はユースケースに沿ったサンプルアプリを紹介することはできなかったため、今後記事などでアウトプットできればと思います!
サンプルコード
今回記事で紹介したサンプルコードを GitHub に置いています。興味があれば是非ご覧ください。

