🦁

Riverpodを正しく使うための心構え v2.4.9

2023/12/26に公開

懺悔します。

ダメな例

今までRiverpodを使っていて、Providerの中で前後比較がしたいと思って方法を考えた末、こんなコードを書いていました。

import 'dart:async';
import 'dart:math' as math;

import 'package:riverpod/riverpod.dart';

final random = math.Random();
final baseStreamController = StreamController<int>.broadcast();
final baseStream = StreamProvider.autoDispose(
  (ref) => baseStreamController.stream,
);
final fooProvider = Provider.autoDispose(
  (ref) {
    ref.listen(
      baseStream,
      (previous, next) {
        final prev = previous?.valueOrNull ?? 0;
        final value = next.valueOrNull ?? 0;
        print('[countProvider] listen: $prev -> $value');
        ref.state = (value - prev).abs() >= 50 ? value : prev;
      },
    );
    return 0;
  },
);

void main(List<String> arguments) {
  final container = ProviderContainer()
    ..listen(
      fooProvider,
      (_, next) => print('[main] listen: $next'),
    );
  Timer.periodic(Duration(seconds: 1), (_) {
    baseStreamController.add(random.nextInt(100));
  });
}

上記は baseStream に値xが与えられた時(0 \le x \lt 100)、前回の値と比較してその差分が50以上だった時に fooProvider の値を更新するというもの。

これをそのまま実行すると次のような出力が得られます。

$ dart run riverpod_legacy
Building package executable... 
Built riverpod_legacy:riverpod_legacy.
[countProvider] listen: 0 -> 85
[main] listen: 85
[countProvider] listen: 85 -> 42
[countProvider] listen: 42 -> 79
[main] listen: 42
[countProvider] listen: 79 -> 4
[main] listen: 4

「データ元がStreamなんだからそっちでよしなに加工すればいいじゃん」「Riverpodって実は状態管理ライブラリじゃなくてぇ……」などの石を投げられても効きません。諸々の背景があってこうなったんです。許してください。

さて、これのどこに問題があるのかというと、基本的に動きはするんですが、色々とProviderを組み合わせていったりすると、Riverpod ver. 2.4.5くらいまではエラーもなく動いていたのに対して ver. 2.4.9にアップデートしたら「Widget Treeの更新中にProviderの状態をいじらないでね」という旨のエラーが出るようになりました。

flutter: Tried to modify a provider while the widget tree was building.
If you are encountering this error, chances are you tried to modify a provider
in a widget life-cycle, such as but not limited to:

...(省略)

良い例

とはいえ前後比較がしたいユースケースというのは変わらないので、これと同じ動きをする、かつRiverpodとして無理のないコードに書き換えました。

final fooProvider = Provider.autoDispose(
  (ref) {
    final stream = ref.watch(baseStream);
    final store = ref.watch(fooStore);
    return store.generateFrom(stream.valueOrNull ?? 0);
  },
);

final fooStore = Provider.autoDispose(
  (ref) => FooStore(),
);

class FooStore {
  int _cache = 0;

  int generateFrom(int newValue) {
    print('[FooStore] generate: $_cache -> $newValue');
    return _cache = (newValue - _cache).abs() >= 50 ? newValue : _cache;
  }
}

ref.listen を取っ払って、かわりに FooStore なるものを導入しました。これは内部に以前の値をキャッシュしてくれるようにロジックを書いているので、_create 関数上で前後比較といった事情を考える必要がありません。

また、これによって fooProvider の宣言自体も簡潔になり、見通しがよくなりました。
ついでに、 FooStore の内部のキャッシュを int get cache => _cache; などで外部から取れるようにしたり、コンストラクタで初期値を決めるようにしたりすれば FooStore 単体でテストを書くことも可能になります(まぁ普通に ProviderContainer から呼び出せばいいし、なんならriverpod_generatorを使っていれば生成元の関数を呼べばいいですがね)。

State(Notifier)Provider以外で状態管理をしようと思わないほうが良い

今回の事件を経て、自分の意識を変えようと思いました。
自分は今まで、「通常のProviderでも内部に状態を持ってるんだったらそれをうまく使えばいいじゃん」と思ってたんですが、そいつはあくまでリアクティブなデータのキャッシュであって、管理するための状態じゃないということを再確認しました。状態管理がしたいのであれば、StateProviderNotifierProvider 等を使え、ということですね。今回は使いませんでしたが。
あと、Flutterで動かすのであればそもそも状態の管理自体は StatefulWidget にまかせてしまう、というのも手です。こっちであれば堂々と ref.listenref.listenManual が使えます。ただこのときに setState を挟もうとすると話がややこしくなるので、そのへんは計画的に。

  int _cache = 0;

  
  void initState() {
    super.initState();
    ref.listenManual(
      baseStream,
      (_, next) {
        final newValue = next.valueOrNull ?? 0;
        _cache = (newValue - _cache).abs() >= 50 ? newValue : _cache;
      },
    );
  }

また、通常のProviderは宣言的になるように書くべきだとも思いました。ref.listen はロギング用途や、手続き的なAPIを使うときのためのもので、状態の更新のために使うのは最終手段です。もしやるならば、挙動が追いづらくなるのを覚悟の上で Future(() {}) 等で状態の更新処理を遅延させるテクニックを多用することになると思います。
そういうテクニックを多用していくのはとりあえず動かすためにやるのはいいですが、結局Riverpodとしても推奨されているわけではないため、上級者向けではあります。

おわりに

というわけで懺悔でした。
皆さんはRiverpod、正しく使えていますか?もしそうじゃないなと感じた方は、時間のあるときに改めて考え直してみてください。

Sun* Developers

Discussion