StatefulWidget を Hooks で書き換える
コントローラの生成と dispose、初期化と後始末——State クラスの定型を Flutter Hooks で畳む。useState と useEffect が StatefulWidget の何に対応するのか、守るべき呼び出し順のルール、そして Riverpod との組み合わせまでを整理する。
前回の入力フォームで、こんな形を書きました。コントローラを生成し、dispose() で解放し、そのために
StatefulWidget と State の 2 クラスを用意する——という手続きです。
class ProfileEditScreen extends StatefulWidget {const ProfileEditScreen({super.key});@overrideState<ProfileEditScreen> createState() => _ProfileEditScreenState();}class _ProfileEditScreenState extends State<ProfileEditScreen> {final _nameController = TextEditingController();@overridevoid dispose() {_nameController.dispose();super.dispose();}@overrideWidget build(BuildContext context) => TextField(controller: _nameController);}
入力欄が 3 つになれば、この定型も 3 セットに増えます。今回扱う Flutter Hooks は、この
「値を保持して、後始末する」という繰り返しを畳むための道具です1flutter_hooks は Flutter 本体ではなく外部パッケージで、React Hooks を発想元にしている。Riverpod と組み合わせる場合は hooks_riverpod を使う。。
§ 01WHYState クラスは何のためにあったか
State クラスの役割を分解すると、多くの場合は次の 3 つに尽きます。
- 再構築をまたいで値を保持する(コントローラ、フラグ、カウンタ)
- 初期化する(
initState) - 後始末する(
dispose)
そして、この 3 つはしばしば一つの値に対してセットで現れます。コントローラを持つなら、それを
初期化し、それを破棄する。にもかかわらず、標準の書き方ではこの 3 つが State クラスの別々の場所に
散らばります。値の宣言はフィールド、初期化は initState、破棄は dispose——関係するコードが
離れて置かれるわけです。
Hooks の発想はここにあります。一つの関心事を、一行にまとめる。
class ProfileEditScreen extends HookWidget {const ProfileEditScreen({super.key});@overrideWidget build(BuildContext context) {final nameController = useTextEditingController();return TextField(controller: nameController);}}
useTextEditingController() の一行が、生成・保持・破棄をまとめて引き受けます2useTextEditingController のほか、useScrollController / useAnimationController / useFocusNode なども同様に、Widget の破棄に合わせて自動で dispose() される。。
StatefulWidget は HookWidget になり、State クラスは消えました。入力欄が 3 つに増えても、
増えるのは 3 行だけです。
§ 02USESTATE変化する値を持つ
useState は、再構築をまたいで生き残る値を作ります。返るのは ValueNotifier で、.value で
読み書きします。
class CounterScreen extends HookWidget {const CounterScreen({super.key});@overrideWidget build(BuildContext context) {final count = useState(0);return Column(children: [Text('${count.value}'),ElevatedButton(onPressed: () => count.value++, // setState 相当child: const Text('+1'),),],);}}
count.value に代入すると、その Widget が再構築されます。setState() を呼ぶ代わりに、値そのものを
書き換える——これが useState です。連載 I で見た「setState は『変わったから描き直して』という
通知」という理解は、そのまま通用します。通知の出し方が変わっただけです。
そして build() は何度でも呼ばれるのに、useState(0) が毎回 0 に戻らないのはなぜか。ここが Hooks の
核心で、HookWidget は呼び出しの「順番」で状態を覚えているからです。「この build の 1 番目の
hook」に対応する状態が保存されていて、2 回目以降はそれが返ります。この仕組みが、後で述べる
ルールの理由になります。
§ 03USEEFFECT副作用と後始末
useEffect は、副作用(購読、タイマー、ログ送信など)とその後始末を書く場所です。
initState と dispose を一箇所にまとめたもの、と考えてください。
useEffect(() {final subscription = itemStream.listen(_handleEvent);return subscription.cancel; // 返した関数が「後始末」になる}, const []); // 第 2 引数が実行タイミングを決める
関数の戻り値が後始末です。return した関数は、Widget が破棄されるとき(あるいは次に効果が
再実行される直前)に呼ばれます。購読を始めたその場に解除を書けるので、片方だけ書き忘れるという
典型的なバグが起きにくくなります。
第 2 引数の keys が実行タイミングを決めます。
keys | いつ実行されるか | State での対応 |
|---|---|---|
| 省略 | 毎回の build | build のたびに走る処理 |
const [] | 初回のみ | initState / dispose |
[userId] | userId が変わったとき | didUpdateWidget での差分処理 |
うっかり keys を省くと毎ビルド実行されます3keys を省略した useEffect は毎回の build で後始末 → 再実行を繰り返す。ストリーム購読やタイマーでこれをやると、意図しない再接続や多重実行になる。。購読やタイマーを毎フレーム張り直すことに
なるので、まず const [] を書く癖をつけて、依存する値があるときだけ足すのが安全です。
もう一つ、useMemoized も覚えておくと便利です。重い生成物を作り直さずに保持するための hook で、
先ほどの useTextEditingController も、実体は useMemoized で作った値に自動破棄を足したものです。
final formatter = useMemoized(() => NumberFormat.decimalPattern(), const []);
§ 04RULES呼び出し順という唯一のルール
Hooks には守るべきルールが一つだけあります。hook は、build のたびに、同じ順番で、同じ回数だけ 呼ぶこと。
状態が「何番目に呼ばれた hook か」で管理されている以上、順番が変わると別の hook の状態を読んで しまうからです4hook の状態は呼び出し順(インデックス)で対応づけられる。build ごとに順番や個数が変われば、前回とは別の状態を読むことになり、型の食い違いや不正な状態になる。。したがって、次はいずれも禁じ手です。
// すべて壊れる書き方if (isEditing) {final controller = useTextEditingController(); // 条件分岐の中}for (final item in items) {useState(item); // ループの中}if (user == null) return const SizedBox(); // hook より前の早期 returnfinal name = useState(user.name);
正しくは、hook は build の先頭にまとめて、無条件で呼びます。条件分岐や早期 return は、hook を すべて呼び終えた後に置きます。
final controller = useTextEditingController(); // 先に全部呼ぶfinal isEditing = useState(false);if (user == null) return const SizedBox(); // 分岐はその後
§ 05RIVERPODConsumerWidget と HookConsumerWidget
ここで、連載 I で保留にしていた話を片付けます。Widget の種類が増えてきたので、選び方を一枚の表に します。
| Widget | ref(共有状態) | hooks(ローカル) |
|---|---|---|
StatelessWidget | – | – |
HookWidget | – | ○ |
ConsumerWidget | ○ | – |
HookConsumerWidget | ○ | ○ |
判断は単純で、必要なものを両方向から数えるだけです。Provider を読むなら ref が要るので
Consumer 系。コントローラやローカルなフラグを持つなら hooks が要るので Hook 系。両方なら
HookConsumerWidget(hooks_riverpod パッケージ)です。
class ItemSearchScreen extends HookConsumerWidget {const ItemSearchScreen({super.key});@overrideWidget build(BuildContext context, WidgetRef ref) {final searchController = useTextEditingController(); // ローカルな入れ物final items = ref.watch(itemListProvider); // 共有状態return Column(children: [TextField(controller: searchController),Expanded(child: ItemList(items: items)),],);}}
境界の引き方は連載 I(LOG-003)と同じです。その画面が閉じたら消えていい値は hooks、画面をまたいで 共有する値は Provider。入力途中の文字列は前者、取得済みのアイテム一覧は後者です。
§ 06BOUNDARYHooks に寄せすぎない
便利なので、つい何でも hook で書きたくなります。ですが、次の 2 点は意識しておく価値があります。
ロジックの置き場所を hooks で代用しない。「検索文字列でフィルタして、並び替えて、件数を数える」 ようなアプリの関心事は、hook に押し込むより Provider や Notifier へ出すほうが、UI なしでテスト できます。hooks が引き受けるのは、あくまでその Widget のローカルな入れ物と副作用です。
標準から外れるコストを忘れない。flutter_hooks は Flutter 本体ではなく外部パッケージです。
また、AutomaticKeepAliveClientMixin のように State を前提とする API と組み合わせたいときは
StatefulHookWidget を使うことになります5StatefulHookWidget / StatefulHookConsumerWidget を使えば、State のライフサイクルメソッドや mixin と hooks を併用できる。ただし両方の作法を読み手に要求することになる。。そこまでして併用する形になったら、その
Widget はふつうに StatefulWidget で書いたほうが読みやすい、というサインかもしれません。
Hooks は「StatefulWidget の上位互換」ではなく、定型を畳むための選択肢です。畳めていないと
感じたら、標準に戻す判断も正解です。
§ 07SUMMARYHooks を使う地図
Stateの役割は保持・初期化・後始末。hooks はこれを一行にまとめるuseStateはValueNotifier。.valueの書き換えがsetStateに相当useEffectは副作用と後始末。戻り値の関数が後始末、keysが実行タイミングkeysはまずconst []。省略すると毎ビルド走る- ルールは一つ。同じ順番・同じ回数で、無条件に呼ぶ。分岐と早期 return は hook の後
refが要るならConsumer系、hooks が要るならHook系、両方ならHookConsumerWidget- 共有する値は Provider、その画面限りの値は hooks
ここまでで、一つの画面を作りきる道具はそろいました。次回は視点を上げて、画面と画面のあいだを
扱います。Navigator のスタックと go_router、そしてタブごとに履歴を保つ仕組みです。
- [1]
flutter_hooksは Flutter 本体ではなく外部パッケージで、React Hooks を発想元にしている。Riverpod と組み合わせる場合はhooks_riverpodを使う。 ↩ - [2]
useTextEditingControllerのほか、useScrollController/useAnimationController/useFocusNodeなども同様に、Widget の破棄に合わせて自動でdispose()される。 ↩ - [3]
keysを省略したuseEffectは毎回の build で後始末 → 再実行を繰り返す。ストリーム購読やタイマーでこれをやると、意図しない再接続や多重実行になる。 ↩ - [4] hook の状態は呼び出し順(インデックス)で対応づけられる。build ごとに順番や個数が変われば、前回とは別の状態を読むことになり、型の食い違いや不正な状態になる。 ↩
- [5]
StatefulHookWidget/StatefulHookConsumerWidgetを使えば、Stateのライフサイクルメソッドや mixin と hooks を併用できる。ただし両方の作法を読み手に要求することになる。 ↩