🎯

状態管理にベストプラクティスは無いが、Riverpodでいい感じにやっていきたい

2024/02/29に公開

状態管理の議論は尽きませんね。Riverpodはわかりにくいだの、setStateで何が行けないんですかなど、GetXで困ってないじゃん、とか。

ライブラリ論争にさしたる意味はない。Hooks vs Riverpodの議論で有益な結論でないでしょ。大抵はローカルかグローバルかのスコープの話、実装にあたってそこまで役に立つかといえば、ちょっと微妙。

このようなUI操作を行う場合は、Hooksならこれを、Riverpodならこれを使うと幸せですっていう勝ちパターンを欲する気持ちは良くわかりますけど、海水を飲むようなものです。喉はずっと渇くよ。

私はFlutterが初手で、Provider→Riverpodの順番に学習したので、Riverpodを使っています。Flutterをやる前にReactの経験がなかった。初手がReactでHooks1年以上やってたら、Hooksに倒していたと思う。FlutterからReactに今は移っているが、Riverpodの包容力の高さは素晴らしいと思う次第。

ただ、setStateで戦い続けるのはかなりきついと思います。initStateがカオスになる未来が見えるので、何かしらライブラリを入れたほうが良いでしょう。

Riverpodで助かってるな〜と感じていること

いくつかあるので、書いていきます。

Widget間で状態を共有できる、Riverpod強い

Riverpodの最大の特徴が、グローバルにデータストアがあること。複数のWidgetが単一のデータソースを指して、変更を自動的に検知してリビルドしてくれます。

これが強いな〜と思うのが、親から子に初期値を渡して、子のWidgetのイベントで親をリビルドするケース。以下のAmazonの画面みたいなやつ。色やサイズを変えたら、上の値段、サムネイル画像、在庫有無などを切り替えないといけない。

このUIをどのようにコンポーネント切るかは現場によって違うと思いますが、単一のWidgetで全部詰め込むケースはないでしょう。1つのページは、複数のWidgetが作られる。watchしておけば対象のWidgetがリビルドされるので、考えることが減る。

family便利

Riverpodで愛用しているのは、familyです。

Providerの初期化に引数が取れるだけじゃなく、引数単位で固有の状態を保持しています。私は当初、引数が変わったら状態が全部リセットされると勘違いしていました。

Tabを切り替えた時にデータをフェッチする系のUIはめっちゃあると思いますが、Familyを使うと何も悩まなくて良くなります。タブの各ページは同じWidgetで、その引数にtabBarのkeyを与えてデータを初期化すれば、タブ単位でデータ保持できます。

Tabに限らず、同じページを持っているがページ単位でデータを管理して可能であればキャッシュしたいみたいな要件なら、family使えばOKです。


Future<List<Product>> productCategory(ProductCategoryRef ref, {required String category) async {
  return ref.read(apiClientProvider).getProduct(category: category);
}

class TabBody extends ConsumerWidget {
  const TabBody({super.key, required String category});
  final String category;
  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(productCategoryProvider.call(category: category));
    return switch (asyncValue) {
      AsyncValue(:final error?) => Text('Error: $error'),
      AsyncValue(:final valueOrNull?) => Text('$valueOrNull'),
      _ => const CircularProgressIndicator(),
    };
  }
}

APIから初期データを貰い、UI操作で選択値を更新

これが最も複雑なパターンだと思う。

上記のAmazonの例で言うと、サイズやカラーの選択肢は商品によって違うので、APIでもらう。
選択肢を貰ったら、初期サイズと初期カラーをセットする。

この場合は、APIからもらう所をFutureProviderでゲットして、AsyncValueに値が入ったら、NotifierProviderFamilyにWidget経由で初期値を渡す実装になると思います。

Riverpodの場合、buildメソッドの中で他のProviderの値を更新することは、非推奨(できないかも)なので、初期値が動的だったらWidget間で値渡しを行うのが素直だと思います。

Providerの依存関係を活用

ある値が更新されたら別の値を更新しないといけない系の話です。
よくあるのが検索条件が変更されたら、該当する検索条件にヒットする件数を返す系のあれ。

この場合は検索条件を管理するProviderと件数を返すProviderを2つ用意して、後者のHitCountを返すProviderで検索条件Providerをwatchします。


Future<int> hitCount(HitCount ref) async {
  // 検索条件の変更をlistenしている。 buildでwatchすると、更新が入ったらrebuildされる
  final condition = ref.watch(conditionProvider);
  return ref.read(apiClientProvider).findUserCount(condition: condition);
}

RiverpodのProviderは、buildメソッドで依存するProviderをwatchしておくと、値が更新されたら自動的にbuildメソッドが呼び出されて状態が最新化されます。ref.listenでも同じことは出来ますが、Widgetのbuildメソッドは可能な限りUIを構築するコードだけにしたい所。

HooksでやるとuseEffectを使うことになると思いますが、buildメソッドの中に関数の記述が増えると認知負荷が上がるので、なるべくRiverpodに寄せている。

Amazonの例だと、色が変わったら写真も変えないといけないので、選択値を管理するProviderと、それをwatchするImageThumbnailProviderを作ってもいいですね。

この種のコードに慣れると、もうRiverpodでいいじゃん・・・になる。自分はそうなった。

Riverpod is not simple, but easy.

RiverpodでAPIからデータをフェッチするコードはこんな感じ。

riverpod

Future<List<AppMessage>> appMessage(AppMessageRef ref) async {
  return ref.read(apiClientProvider).getAppMessage();
}

それを使うWidgetは以下のように書いて、あとはページ固有のWidgetを埋め込めば良い。

class Consumer extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final value = ref.watch(appMessageProvider);

    return switch (value) {
      AsyncValue(:final error?) => Text('Error: $error'),
      AsyncValue(:final valueOrNull?) => Text('$valueOrNull'),
      _ => const CircularProgressIndicator(),
    };
  }
}

誰がやってもだいたい同じようなコードになると思う。ただ、単純ではない。初見だと何がどうなってんのコレ、になる。

Riverpodを使うだけでいい感じにできるのですが、そこに至るまでの登場人物が多い。
Consumer,ConsumerWidget,AsyncValue,FutureProvider,WidgetRef,ProviderRef...

単純さならHooksには到底敵わない。単純だから実装も簡単・・・にはならないのがHooksの難しさ。SimpleなAPIは組み合わせて使わないと行けないので、考えることが多く、可読性が下がることも大いにあります。useEffectは、ピタゴラスイッチになりやすいので。

local stateについて

Riverpod公式ドキュメントに、下記のような記載があります。
Widget単体の状態管理にProviderを使うのをやめようって読めるから、ユーザーのようなアプリ全体で共有しない種のデータ管理に、Hooksを使わないといけない。これ、早とちりだと思います。

AVOID using providers for local widget state.
Providers are designed to be for shared business state. They are not meant to be used for local widget state, such as for:
storing form state
currently selected item
animations
generally everything that Flutter deals with a "controller" (e.g. TextEditingController)
If you are looking for a way to handle local widget state, consider using flutter_hooks instead.
https://riverpod.dev/docs/essentials/do_dont

ここで言っているのは、下記のオブジェクトをRiverpodでグローバルに管理する必要はないってこと。これをlocal Stateと言っている。TextEditingControllerを引き回して状態を共有する意味がない。

  • FormState
  • TextEditingController
  • ScrollController
  • AnimationController

currently selected itemは最後に選択したアイテムを残して画面を戻す場合がある(検索条件の保持など)ので、この限りではない。WidgetTreeから消えて破棄していいなら useStateでも問題ないと思うけど、RiverpodのautoDisposeと動きは一緒。

自分がHooksを使っている局面は、以下の通り。

  • xxControllerの初期化
  • useEffect
  • useAppLifecycleState

useEffectは大きく2つあって、1つはcontrollerのイベント解除、もう1つはビルドした後にダイアログを出したい(ポップアップのお知らせなど)場合に利用しています。それ以外は、あんまり利用していない。

useAppLifecycleStateは、resumeを拾ってAPI叩きたいケースがあるので、そこで使ってます。app全体で捕まえたり、個別のWidgetに適用することも出来ます。

Riverpodは便利

Providerの世界観になれるまでが大変だけど、慣れちゃったら、ほんま楽になると思う!

SimpleじゃないけどEazyなのがRiverpodの魅力だと思う次第です。

Discussion