この記事は 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 に置いています。興味があれば是非ご覧ください。