Riverpodを正しく使うための心構え v2.4.9
懺悔します。
ダメな例
今まで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
に値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でも内部に状態を持ってるんだったらそれをうまく使えばいいじゃん」と思ってたんですが、そいつはあくまでリアクティブなデータのキャッシュであって、管理するための状態じゃないということを再確認しました。状態管理がしたいのであれば、StateProvider
や NotifierProvider
等を使え、ということですね。今回は使いませんでしたが。
あと、Flutterで動かすのであればそもそも状態の管理自体は StatefulWidget
にまかせてしまう、というのも手です。こっちであれば堂々と ref.listen
や ref.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、正しく使えていますか?もしそうじゃないなと感じた方は、時間のあるときに改めて考え直してみてください。
Discussion