Riverpod における非同期処理て、複雑でむずかしくないですか?
例えば AsyncValue は、データ、ローディング、エラーという 3 つの状態を内包しているだけでなく、Riverpod v2 からは更に、リロードなど新しい概念も登場しました。
そんな AsyncValue を正しく扱えず、アプリケーションが意図した状態にならなかったり、思わぬ例外が throws され続け、オンコール対応に明け暮れたりと、苦労させられた経験があります。
今回は Riverpod でアプリケーションを開発・運用していく中で陥った罠と、そこから得られたベストプラクティスを紹介します。
value VS valueOrNull
どちらも以前に取得したデータを返します。しかし、以前に取得した値がない (エラー状態) の場合は、value は例外を throws してしまいます。
final anyFutureProvider = FutureProvider((ref) {
// 何らかの例外が throws された
});
// value でアクセスしようとすると例外が throws される
ref.read(anyFutureProvider).value;
よって、value
を使う場合は原則 hasValue
によるチェックを行うか、もしくはエラー状態でないことが確信できる場合に限定して value
を使うようにした方が良いでしょう。
// good
final any = ref.read(anyFutureProvider);
if (any.hasValue) {
any.value;
}
もしくはよりセーフティな valueOrNull
を使うことです。valueOrNull
の場合は、ローディングやエラー状態のときは null を返してくれます。
// good
ref.read(anyFutureProvider).valueOrNull;
value (valueOrNull) の罠
前述のように、どちらも以前に取得したデータを返します。しかし、最新の状態を返す訳ではありません。
例えば、データの取得完了後、ローディング状態に変化した AsyncValue に対して value
(valueOrNull
) でアクセスすると、以前に取得したデータが返ってきます。
以前取得したデータがあるかどうかに関わらず、いま最新の状態を取得し、それに応じて UI を出し分けたいといった要件がある場合は unwrapPrevious()
の使用を検討しましょう。
// good
ref.read(anyFutureProvider).unwrapPrevious()?.valueOrNull;
unwrapPrevious()
は、以前に取得したデータの情報を持たない AsyncValue を返してくれます。
Future を await するときの罠
FutureProvider を ref.read (ref.watch) するときに、以下のようなコードを書くかもしれません。
await ref.read(anyFutureProvider.future);
これは一見正しいコードに見えますが、FutureProvider 内で例外が throws されると rethrows されてしまいます。try-catch で適切なエラーハンドリングを行うと良いでしょう。
// good
try {
await ref.read(anyFutureProvider.future);
} catch (error) {
// Do something.
}
AsyncNotifier における state 更新の罠
Notifier における state 更新と同様に、以下のようにして状態を更新していませんか?
state = AsyncDate('newValue');
これは間違ったコードではありませんが、以下のようなケースが起こり得ることは考慮しておくと良いでしょう。
- 非同期処理が走っている間に代入で state 更新をしてしまうと、後から値が書きかわってしまい、意図した状態にならない可能性がある
update(:cb,onError:)
関数を使うと、非同期処理の完了を待ってから状態の更新を行うため、こういった事態を防ぐことができます。
update((previous) => "newValue");
ただし、状態がエラーの場合に update(:cb,onError:)
を呼び出すと例外が throws されてしまうため、try-catch で囲むか、エラー時に呼び出される onError
へコールバックを渡すようにしておきましょう。
// good
update(
(previous) => "newValue",
onError: (error, stackTrace) => "error",
);
さいごに
FutureProvider や AsyncNotifierProvider のライフサイクルは複雑です。今回紹介した tips はほんの一例です。
わたし自身現在も尚実践していますが、ProviderObserver
を使って、Provider の生成や破棄の挙動を観察しながら、丁寧に開発を進めていくことをおすすめします。
ProviderScope(
observers: [MyProviderObserver()],
// ...
);
class MyProviderObserver extends ProviderObserver {
@override
void didDisposeProvider(ProviderBase<Object?> provider, ProviderContainer container) {
print('${provider.runtimeType} was disposed');
}
// ...
}