🌊

Riverpod v2のAsyncValueを理解する

2022/08/12に公開
CHANGELOG
  • 2022.11.23
  • 2022.11.06
    • v2.1.0 のリリースに伴い v2.0.x を元に執筆している記事である旨を明記
  • 2022.10.30
    • 9月末の v2.0.0安定版提供に伴い関連箇所を一部修正
    • とくに .future .stream の削除による関連セクションの削除
    • Provider<AsyncValue<T>> を使わず FutureProvider StreamProvider をそのまま返却する書き方に変更

はじめに

Riverpod、非常に便利で強力な状態管理パッケージですね。
私が最初に触り始めたのは約2年前頃の v0.6.0 ですが、そこから v1のリリース・v2のプレリリースと着々と進化を遂げているのが分かります。
そんな有用パッケージですが、ドキュメント 自体は用意されているものの(数ヶ月前日本語訳も提供 されましたね👏)、その内容は Provider の種類や使い分けや修飾詞に留まっており、少しまだ物足りなさを感じます。Flutter のドキュメントが手厚すぎるがゆえに相対的にそう見えてしまっているだけかもしれません。
今回は、上記のドキュメントにはそこまで手厚く記載されてはいないものの、Riverpod を扱う上で重要な AsyncValue について触れていきます。
AsyncValueFutureProvider/StreamProvider/AsyncNotifierProvider を扱う際に欠かせないクラスで、Riverpod の作者もこれら Provider には長期的に大きな計画を立てていると言及しており、益々利用機会は増えてくるのだと思います。

And to be fair, I have a more long-term vision of Riverpod.
I have big plans for FutureProvider, while StateNotifierProvider is going to become less useful over time.
https://twitter.com/remi_rousselet/status/1537563116220952578

対象の読者

  • Riverpod のシンタックスや Provider の種類など基本的なことがかる方
  • StateNotifierProvider で MVVM 的に実装してきたが機能単位で切り離したいと考えている方
  • AsyncValue の使い方や振る舞いの理解に自信がない方

AsyncValueクラス

改めて、AsyncValue は Future や Stream などの非同期処理で発生する loading/error 状態を簡単にハンドリングできるクラスです。
https://pub.dev/documentation/riverpod/latest/riverpod/AsyncValue-class.html

ソースコード上は common.dart に集約されています。

コードの通り、AsyncValue は sealed クラスとなっており、dataloadingerror の3つが factory で定義されています。

const factory AsyncValue.data(T value) = AsyncData<T>;
const factory AsyncValue.loading() = AsyncLoading<T>;
const factory AsyncValue.error(Object error, {StackTrace? stackTrace}) = AsyncError<T>;

他にも、try~catch を省く guard() メソッドや、isLoading プロパティなどが用意されています。

直前の状態を維持するcopyWithPreviousメソッド

AsyncValue は3つの状態を持ちますが、以下の Provider のみ状態が変化する際にデフォルトで直前の値を維持する性質があります。いずれも非同期処理を扱う Provider です。

  • FutureProvider
  • StreamProvider
  • AsyncNotifeirProvider

元々、StateNotifierProvidercopyWithPrevious の性質を持っていたのですが、v2から上記3つに限定されました。基本的にこの性質は、非同期処理におけるユーザーインタフェースを向上させる点に効果を発揮するため、その役割を明確にしたのだと思います。

FutureProvider, StreamProvider and AsyncNotifierProvider now preserve the previous data/error when going back to loading. This is done by allowing AsyncLoading to optionally contain a value/error.
https://pub.dev/packages/riverpod/changelog#210

たとえば、初回でのデータ取得が成功した後、一時的なサーバエラーなどでその後の取得に失敗した場合には AsyncError が返ってきますが、初回で取得したデータはそのまま保持しています。これによりユーザーはエラーが発生してしまった場合でも、直前のコンテンツはそのまま閲覧でき、利用体験を維持できます(エラー内容を全面的にユーザーに伝えることもでき、開発者側で自由にハンドリングが可能です)。
これを実現しているのが copyWithPrevious メソッドで、各継承先クラスで override されているのが分かります。

詳細はソースコードを見ると良いと思いますが、まとめると以下の表となります。
注意なのは、AsyncLoading クラスは初回読み込み時にしか返却されないという点です。

クラス 挙動
AsyncData 前の状態に関係なく最新の AsyncData で上書き
AsyncLoading 前の状態に isLoading: true に変えて返却。初回読み込み時のみ AsyncLoading クラスが返る。
AsyncError 最新のエラー情報は返しつつ前の状態にデータがあれば維持

こちらにソースコードを抜粋して記載しました。

copyWithPrevious の該当ソースコード
class AsyncData<T> extends AsyncValue<T> {
  // 前の状態(previous)に関係なく問答無用でAsyncDataで上書きしていることが分かります。
  // 正常にデータ取得ができた場合は直前のエラー情報などは不要という点で納得です。
  
  AsyncData<T> copyWithPrevious(AsyncValue<T> previous) {
    return this;
  }
}

class AsyncLoading<T> extends AsyncValue<T> {
  // AsyncLoadingは前の状態に`isLoading: true`を付与して返却していることがわかります。
  // 前の状態が成功している場合は`AsyncData(isLoading: true)`
  // 前の状態が失敗している場合は`AsyncError(isLoading: true)`
  // 初回読み込み時のみAsyncLoadingを返します。
  
  AsyncValue<T> copyWithPrevious(AsyncValue<T> previous) {
    return previous.map(
      data: (d) => AsyncData._(
        d.value,
        isLoading: true,
        error: d.error,
        stackTrace: d.stackTrace,
      ),
      error: (e) => AsyncError._(
        e.error,
        isLoading: true,
        value: e.valueOrNull,
        stackTrace: e.stackTrace,
        hasValue: e.hasValue,
      ),
      loading: (_) => this,
    );
  }
}

class AsyncError<T> extends AsyncValue<T> {
  // 最新のエラー内容やスタックトレースは返しつつ、
  // 前の値の存在有無やそのデータがある場合は保持していることがわかります。
  // これによりAsyncErrorでもpreviousのデータを表示することが可能になっています。
  
  AsyncError<T> copyWithPrevious(AsyncValue<T> previous) {
    return AsyncError._(
      error,
      stackTrace: stackTrace,
      isLoading: isLoading,
      value: previous.valueOrNull,
      hasValue: previous.hasValue,
    );
  }
}

isRefreshing

2.0.0-dev.0 から導入されている機能で、初回表示後のデータ更新の際に、読み込み中でも前のデータやエラーを UI としてそのまま表示できます。

After a provider has emitted an AsyncValue.data or AsyncValue.error, that provider will no longer emit an AsyncValue.loading.Instead, it will re-emit the latest value, but with the property AsyncValue.isRefreshing to true.

実装は単純で、AsyncDataAsyncError の「valueerror が存在する状態」から再読み込みした時に true と評価されることが分かります。

  bool get isRefreshing {
    return isLoading && (hasValue || hasError);
  }

Riverpod のリポジトリに isRefreshing のテスト があるので、これを元に挙動を理解できます。型判定の部分のみ追加してあります。

  // ref. https://github.com/rrousselGit/riverpod/blob/2ed9fa0091ff1d0c3d0d37d89a3d5de06c8105d7/packages/riverpod/test/framework/async_value_test.dart#L76
  test('isRefreshing', () {
    // AsyncLoading
    expect(const AsyncLoading<int>().isRefreshing, false);
    final previousIsLoading = const AsyncLoading<int>().copyWithPrevious(
      const AsyncLoading(),
    );
    expect(previousIsLoading.isRefreshing, false);
    expect(previousIsLoading, isA<AsyncLoading<int>>());

    // AsyncData
    expect(const AsyncData<int>(42).isRefreshing, false);
    final previousIsData = const AsyncLoading<int>().copyWithPrevious(
      const AsyncData<int>(42),
    );
    expect(previousIsData.isRefreshing, true);
    expect(previousIsData, isA<AsyncData<int>>());

    // AsyncError
    expect(const AsyncError<int>('err').isRefreshing, false);
    final previousIsError = const AsyncLoading<int>().copyWithPrevious(
      const AsyncError<int>('err'),
    );
    expect(previousIsError.isRefreshing, true);
    expect(previousIsError, isA<AsyncError<int>>());
  });

具体例

よくある一般的な API でのデータ取得の動きとして、「初回のデータ取得後にリフレッシュした場合の挙動」を以下の図にまとめました。

要点は「(3)リフレッシュ」時に AsyncLoading ではなく AsyncData(isLoading: true)となっている点です。前述の copyWithPrevious でも述べましたが、AsyncLoading の copyWithPrevious は「前の状態に isLoading: true として返却」の挙動となるため、こういったケースで isRefereshing: true となります。AsyncError からリフレッシュする時もほぼ一緒の挙動となります。

  test('初回のデータ取得後にリフレッシュした場合', () {
    // ①初回読み込み
    const loading = AsyncLoading<int>();
    expect(loading.isLoading, isTrue);
    expect(loading.isRefreshing, isFalse, reason: 'previousがないのでfalse');
    expect(loading, isA<AsyncLoading<int>>());

    // ②データ取得成功
    final initialValue = const AsyncData(1).copyWithPrevious(loading);
    expect(initialValue.value, 1);
    expect(initialValue.hasValue, isTrue);
    expect(initialValue.isLoading, isFalse);
    expect(initialValue.isRefreshing, isFalse);
    expect(initialValue, isA<AsyncData<int>>());

    // ③リフレッシュ(Pull-to-Refreshなどで更新)
    final refreshing = const AsyncLoading<int>().copyWithPrevious(initialValue);
    expect(refreshing.hasValue, isTrue, reason: 'initialValueの1があるのでtrue');
    expect(refreshing.isLoading, isTrue);
    expect(refreshing.isRefreshing, isTrue, reason: 'hasValueがtrueなのでtrue');
    expect(refreshing, isA<AsyncData<int>>(), reason: 'previousがAsyncDataのため');

    // ④更新完了
    final value = const AsyncData<int>(2).copyWithPrevious(refreshing);
    expect(value.value, 2);
    expect(value.hasValue, isTrue);
    expect(value.isLoading, isFalse);
    expect(value.isRefreshing, isFalse);
    expect(value, isA<AsyncData<int>>());
  });

これによってユーザー目線では、(2)で取得した値が表示されたまま、裏で(3)のリフレッシュ処理が行われ、(4)の取得完了と同時にパキッと値が切り替わる形になります。
逆に、リフレッシュと初回読み込みの UI を統一したい(ex. 読込中は常にインジケータを表示させたい)といった場合には、AsyncData,AsyncError でも isLoading: true となるので、普通に isLoading 判定だけすれば良いですね。

値の取り出しと便利なシンタックス

AsyncValue には他にもさまざまなメソッドや拡張が用意されています。前述した isRefereshing も extension で用意されている getter の1つです。テストコードが async_value_test.dart に書かれているのでこちらを動かしてみるのもオススメです。今回はその中で、個人的にとくによく使うものをピックアップして記載します。

https://pub.dev/documentation/riverpod/latest/riverpod/AsyncValueX.html

when・whenOrNull・mayBeWhen

もっとも馴染みのある extension が when だと思います。他の記事でもたくさん言及されておりますが、data/loading/error の3状態に応じて表示を切り替えることができます。

.when の特徴

  • AsyncValue を取り出す際のもっともスタンダード(主観)な書き方
  • 3状態に応じて表示を切り替えることができる
  • 丁寧なハンドリングが不要な場面ではやや冗長となる側面がある
buildメソッドでの使用例

Widget build(BuildContext context, WidgetRef ref) {
  return ref.watch(someValueProvider).when(
    loading: CircularProgressIndicator.new,
    error: (error, stacktrace) => Text(error.toString()),
    data: (value) {
      // AsyncDataの値
      return Text('$value');
    },
  );
}

前述の copyWithPrevious の通り、2回目以降の読み込みは AsyncLoading クラスにはならないので、上記の実装ではインジケータは表示されません。初回・2回目以降関係なくインジケータを表示したい場合は、isRefreshingisLoading でも同様)の分岐を追加してあげる必要がありますね。

ついでに whenOrNullmayBeWhen も紹介します。名前からも分かりますが、when を基準にユースケースによって取り扱いしやすくした extension となっています。

.mayBeWhen の特徴

  • ハンドリングしたい状態のみの実装で済む
  • 他の状態は orElse() でまとめることができる

error 状態をとくに考慮する必要ない場面などで利用できますが、後述の .value で済むケースが多いのであまり出番はありません。

データ取得時以外はインジケータを表示する例
ref.watch(someValueProvider).maybeWhen(
  // loading/error時にはインジケータが表示される
  orElse: CircularProgressIndicator.new,
  data: (value) {
    return Text('$value');
  },
);

.whenOrNull の特徴

  • .mayBeWhenorElse() の代わりに null を返す
データ取得時以外はnullを返す例
ref.watch(someValueProvider).maybeWhen(
  // loading/error時にはnull
  data: (value) {
    return Text('$value');
  },
);

whenData

whenData は、AsyncData を加工した後に AsyncValue を返却する動きをします。
そのため、上記の when 系のメソッドとは異なり build メソッドで Widgetをそのまま返却できず、Provider 内で AsyncData を加工する用途で用意されているのだと思います。

.whenData の特徴

  • 必要に応じて AsyncData のみ加工したい場合に利用
  • 加工処理で exception を throw した場合は AsyncError となって返却される
  • AsyncLoadingAsyncErrorwhenData の加工後の型で返却される
    • 型が変わらない場合は見た目上そのまま返却されるように見える
whenData の該当ソースコード
/// Shorthand for [when] to handle only the `data` case.
///
/// For loading/error cases, creates a new [AsyncValue] with the corresponding
/// generic type while preserving the error/stacktrace.
AsyncValue<R> whenData<R>(R Function(T value) cb) {
  return map(
    data: (d) {
      try {
        return AsyncData._(
            cb(d.value),
            isLoading: d.isLoading,
            error: d.error,
            stackTrace: d.stackTrace,
          );
      } catch (err, stack) {
        return AsyncError._(
            err,
            stackTrace: stack,
            isLoading: d.isLoading,
            value: null,
            hasValue: false,
          );
      }
    },
    error: (e) => AsyncError._(
      e.error,
      stackTrace: e.stackTrace,
      isLoading: e.isLoading,
      value: null,
      hasValue: false,
    ),
    loading: (l) => AsyncLoading<R>(),
  );
}
whenData の挙動のテストコード
whenDataの挙動
test('transforms data if any', () {
  expect(
    const AsyncValue.data(42).whenData((value) => '$value'),
    const AsyncData<String>('42'),
  );
  expect(
    const AsyncLoading<int>().whenData((value) => '$value'),
    const AsyncLoading<String>(),
  );
  expect(
    const AsyncError<int>(21).whenData((value) => '$value'),
    const AsyncError<String>(21),
  );
});

test('catches errors in data transformer and return AsyncError', () {
  expect(
    const AsyncValue.data(42).whenData<int>(
      // ignore: only_throw_errors
      (value) => throw '42',
    ),
    isA<AsyncError<int>>()
        .having((e) => e.error, 'error', '42')
        .having((e) => e.stackTrace, 'stackTrace', isNotNull),
  );
});

whenData を使って値の加工・型の変更する Provider の例が以下になります。最終的に Widget側で取り扱いやすいように加工する Provider を挟むと、UI 側にロジックが介在せずスッキリしますね。

whenDataを使ってProviderを加工する例
final countProvider = StreamProvider<int>((ref) => Stream.value(42));

// v2.0.0-dev.9 以前
// `whenData`を使って`Provider<AsyncValue<T>>`に変換して返す書き方をしていた
final doubleCountLabelProvider = Provider<AsyncValue<String>>((ref) {
  return ref.watch(countProvider).whenData((count) => '${count * 2}');
});

// v2.0.0 以降
// `.future`,`.stream`が廃止されたことで`Provider<AsyncValue<T>>`が
// StreamProvider/FutureProviderの劣化版になってしまい利用価値が無くなったためそのまま変換する
final doubleCountLabelProvider = StreamProvider<String>((ref) {
  return ref.watch(countProvider.stream).map((count) => '${count * 2}');
});

// 省略...

Widget build(BuildContext context, WidgetRef ref) {
  return ref.watch(doubleCountLabelProvider).when(
      data: Text.new, // -> 84
      error: (error, _) => Text(error.toString()),
      loading: CircularProgressIndicator.new,
  );
}

value・valueOrNull

前述の when 系メソッドは状態に応じて丁寧に表示の切り分けができる一方で、そこまで丁寧に分岐が必要ではないケースもあります。たとえば Firestore のリアルタイムリスナーでの取得では、瞬時にデータが流れてくるためインジケータを表示するまでもありません。また、優秀なキャシュ機構によってエラーとなるケースもありません(あるとすれば index の貼り忘れやセキュリティルールで弾かれるなどですが、いずれも開発段階で気づけるもので、そのために分岐をするのは単に冗長なコードになってしまいます)。あるいは、単に build メソッドでのネストブロックが気になる開発者もいるかもしれません。
valuevalueOrNull は AsyncValue から直接値を参照できるもので、上記のような各状態のハンドリングが不要なケースで使用します。

.value の特徴

  • AsyncLoading では null が返る
  • AsyncError では exception が throw される
  • 再評価時(isRefreshing: true)の時に exception が発生した場合は前で取得した値が返る

error状態ではexceptionがthrowされる

こちらは元々throw されずに value が返ってくる仕様だったのですが、こちらのイシュー で議論され、2.0.0-dev.6 で、初回読み込み時のみ exception が throw されるようになりました。
2回目以降の読み込み時は、エラーが発生したのか一見気づきにくい問題がありますが、大抵のケースで予期せぬエラーが生じるのは初回読み込み時とコメントされている通りで納得ですね。この変更に伴い、既存の AsyncError 時にも value を常に返す extension として valueOrNull が追加されました。そのため、.valueOrNull.value と比べて以下の違いがあるのみです。

.valueOrNull の特徴

  • 基本的には .value とほぼ一緒
  • .value との違いは AsyncError でも常に value が返る点
  T? get valueOrNull {
    if (hasValue) return value;
    return null;
  }

各Provider間での受け渡し例

N 番煎じで恐縮ですが、mono さん起票のイシューコメントに書き換え例が記載されており、これを見るのが一番わかりやすいです(毎度ありがとうございます🙏)。
https://github.com/rrousselGit/riverpod/issues/1712#issuecomment-1264961836

2.0.0-dev.9までの書き方例
// ①StreamProvider
final initialProvider = StreamProvider<int>((ref) => Stream.value(1));

// ②StreamProvider -> FutureProvider
final twiceAfterSecondProvider = FutureProvider<int>((ref) async {
  final value = await ref.watch(initialProvider.future); // -> 1
  return Future<int>.delayed(const Duration(seconds: 1), () {
    //  1 -> 2
    return value * 2;
  });
});

// ③FutureProvider -> Provider<AsyncValue>
final twiceProvider = Provider<AsyncValue<int>>((ref) {
  final value = ref.watch(twiceAfterSecondProvider);
  return value.whenData((value) {
    //  2 -> 4
    return value * 2;
  });
});

// ④Provider<AsyncValue> -> StreamProvider
final moreTwiceProvider = StreamProvider<int>((ref) {
  //  4 -> 8
  return ref.watch(twiceProvider.stream).map((e) => e * 2);
});


Widget build(BuildContext context, WidgetRef ref) {
  ref.watch(moreTwiceProvider).value; // -> 8
}

https://twitter.com/_mono/status/1455110929155178499

copyWithPreviousの挙動を細かくハンドリングする

直前の状態を維持するcopyWithPreviousメソッド で紹介した非同期 Provider では、とくに何も指定しなければデフォルトで copyWithPrevious の挙動となりますが、v2.1.0 からさまざまな要件に対応できるよう細かいハンドリングができるようになりました。

Added AsyncValue.when(skipLoadingOnReload: bool, skipLoadingOnRefresh: bool, skipError: bool) flags to give fine control over whether the UI should show loading or data/error cases.
https://pub.dev/packages/riverpod/changelog#210

具体的には、値の取り出しと便利なシンタックス で紹介した .when などのシンタックス利用時に、以下の bool プロパティを指定できます。デフォルト値と挙動はドキュメントから参照しました。

boolプロパティ デフォルト 挙動
skipLoadingOnReload false ref.watch でのリロード時(再評価時)に AsyncLoading 状態にするか否か
skipLoadingOnRefresh true ref.refresh でのリフレッシュ時に AsyncLoading 状態にするか否か
skipError false 直前の value が存在する場合に、エラーではなくdataを呼び出すか否か

https://pub.dev/documentation/riverpod/latest/riverpod/AsyncValueX/when.html

「とくに何も指定しなければデフォルトで copyWithPrevious の挙動となりますが」と前述した通り、何も指定しない場合は copyWithPrevious が働き、リフレッシュ操作などしてもいわゆる「Loading 状態(=AsyncLoading クラス)」にはなりません。ん? となった方は isRefreshing を再度ご参照ください。

一方で、ref.watch によりプロバイダが再評価される場合は一度 AsyncLoading を経由します(ドキュメントではこの挙動をリフレッシュと対比してリロードと表現しています)。

例を以下に示します。数値を返す someProviderdoubleEnabledProvider を watch しています。この場合、doubleEnabledProvider の値が更新されると、someProvider が再評価される(=someProvider のコールバック内が呼ばれる)ため、AsyncLoading クラスを経由後に結果が得られます。

// 数値を2倍にするか否かを管理するプロバイダ
// `someProvider`からwatchされている
final doubleEnabledProvider = StateProvider((ref) => false);

final someProvider = FutureProvider.autoDispose((ref) async {
  await Future<void>.delayed(const Duration(seconds: 2));

  // `doubleEnabledProvider`が更新されると、参照元である`someProvider`が再評価される
  // つまり、`doubleEnabledProvider`が更新される度にAsyncLoadingクラスを経由する
  return Future.value(42 * (ref.watch(doubleEnabledProvider) ? 2 : 1));
});


Widget build(BuildContext context, WidgetRef ref) {
  // 再評価されるとAsyncLoading, つまりCircularProgressIndicatorのクルクル表示になります
  // 対してリフレッシュ操作では表示がパキッと切り替わります。
  return ref.watch(someProvider).when(
    // skipLoadingOnReload: false,
    // skipLoadingOnRefresh: true,
    // skipError: false,
    loading: CircularProgressIndicator.new,
    data: (data) => Text('data: $data'),
    error: (error, stackTrace) => Text('error: $error'),
  );
}

それぞれの違いをまとめると以下となります。

someProviderが更新される挙動
## Reloading
- 契機: `doubleEnabledProvider`が更新された時(再評価)
- 状態変化: AsyncData → AsyncLoding(isReloading:true) → AsyncData

## Refresh
- 契機: pull-to-refresh操作などで`ref.refresh`によってリフレッシュされた時
- 状態変化: AsyncData → AsyncData(isRefreshing:true) → AsyncData

以上がデフォルトの挙動で、これらを細かくハンドリングできるのが本セクション冒頭で紹介した bool プロパティとなります。たとえば、「リフレッシュ操作時でも AsyncLoading クラスを経由して copyWithPrevious の振る舞いをせずに一から取得したい」といった要望も skipLoadingOnRefresh を false にすることで簡単に満たせます。

本セクションのプロパティはあくまでオプショナルなので、ややこしいと感じた方はデフォルトの挙動に従うという選択もありです。ちなみに、個人的にも基本はデフォルト値に従う方針です(デフォルトがユーザー体験的にもっとも自然な挙動になっていると思うのが理由です)。

まとめ

今回は Riverpod(v2)の AsyncValue を題材に、前の値を合成するその挙動や isRefreshing、便利なシンタックスなどを取り扱ってきました。個人的に、昔は StateNotifierProvider を使って MVVM っぽい作りで実装していた都合で AsyncValue を取り扱う機会が正直あまりなかったのですが、疎結合的に切り離して実装するようになってからは FutureProvider/StreamProvider を利用する機会が増え AsyncValue の使いやすさに気づき始めました。
なんとなく「非同期処理の状態をハンドリングしてくれるクラス」程度に思っていた AsyncValue ですが、執筆にあたって色々調べてみると、前の値の合成やエラーハンドリングの措置、各種 extension など、開発者にとって扱いやすいケアがたくさんなされていることに気づきました。

今回は、上記のドキュメントにはそこまで手厚く記載されてはいないものの、

冒頭にこのように記載しましたが、API ドキュメントはじめとくにテストコードが丁寧に書かれており、これらを読むなり実際に手元で動かしたりするなど、動かしてみるともっとも理解が進みました。

執筆時点ではまだ Prerelease 版でしたが、安定版提供されて落ち着いてきましたね。これから本格的に Riverpod を使い始める方や v2へ以降する方などの参考になれば嬉しいです。

参考

Discussion