📝

Riverpod: FutureProvider について

2024/11/05に公開

FutureProvider は以下の単純なユースケースを解決するために設計されている

  • 非同期操作を実行し、その結果をキャッシュする(ネットワークリクエストなど)
  • 非同期操作の error/loading 状態を適切に処理する
  • 非同期的に取得した複数の値を組み合わせて一つの値にする

Hello, FutureProvider!

3秒後に Hello, FutureProvider! という文字列を返すFutureProviderを用意する.

Riverpod Generator 未使用

final helloFutureProvider = FutureProvider<String>((ref) {
  return Future<String>.delayed(
      const Duration(seconds: 3), () => 'Hello, FutureProvider!');
});

Riverpod Generator 使用

(keepAlive: true)
Future<String> helloFuture(Ref ref) async {
  return Future<String>.delayed(
      const Duration(seconds: 3), () => 'Hello, FutureProvider!');
}

FutureProvider Demo

ref.invalidate でデータを再取得するように見せる画面を実装する:

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final helloFutureProvider = FutureProvider<String>((ref) {
  return Future<String>.delayed(
      const Duration(seconds: 3), () => 'Hello, FutureProvider!');
});

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'FutureProvider Demo',
      home: FutureProviderDemo(),
    );
  }
}

class FutureProviderDemo extends ConsumerWidget {
  const FutureProviderDemo({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final result = ref.watch(helloFutureProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('FutureProvider Demo'),
      ),
      body: Center(
        child: switch (result) {
          AsyncData(:final value, isLoading: false) => Text(value),
          _ => const CircularProgressIndicator(),
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.invalidate(helloFutureProvider),
        child: const Icon(Icons.refresh),
      ),
    );
  }
}
AsyncLoading AsyncData

エラーハンドリング

FutureProviderがUnimplementedErrorを投げるように修正する:

/// 3秒後にUnimplementedErrorを返す
final helloFutureProvider = FutureProvider<String>((ref) {
  throw UnimplementedError('UnimplementedError!');
});

エラーを参照する

パターンを活用してエラーを参照する:

switch (result) {
  AsyncData(:final value, isLoading: false) => Text(value),
  AsyncError(:final UnimplementedError error) => Text('${error.message}'),
  _ => const CircularProgressIndicator(),
}

エラーが発生した時にメッセージやダイアログ等を表示する場合も同様:

ref.listen(helloFutureProvider, (prev, next) {
  switch (next) {
    case AsyncError(:final UnimplementedError error, isLoading: false):
      scaffoldMessenger.showSnackBar(SnackBar(
        content: Text('${error.message}'),
      ));
  }
});

Flutter Hooks を活用するパターンもある:

final result = ref.watch(helloFutureProvider);
final scaffoldMessenger = ScaffoldMessenger.of(context);

useEffect(() {
  switch (result) {
    case AsyncError(:final UnimplementedError error, isLoading: false):
      WidgetsBinding.instance.addPostFrameCallback((_) {
        scaffoldMessenger.showSnackBar(SnackBar(
          content: Text('${error.message}'),
        ));
      });
  }
  return;
}, [result]);

FutureProvider Error Demo

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final helloFutureProvider = FutureProvider<String>((ref) {
  return Future<String>.delayed(const Duration(seconds: 3),
      () => throw UnimplementedError('UnimplementedError!'));
});

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'FutureProvider Demo',
      home: FutureProviderDemo(),
    );
  }
}

class FutureProviderDemo extends ConsumerWidget {
  const FutureProviderDemo({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final result = ref.watch(helloFutureProvider);

    ref.listen(helloFutureProvider, (prev, next) {
      switch (next) {
        case AsyncError(:final UnimplementedError error, isLoading: false):
          ScaffoldMessenger.of(context).showSnackBar(SnackBar(
            content: Text('${error.message}'),
          ));
      }
    });

    return Scaffold(
      appBar: AppBar(
        title: const Text('FutureProvider Demo'),
      ),
      body: Center(
        child: switch (result) {
          AsyncData(:final value, isLoading: false) => Text(value),
          AsyncError(:final UnimplementedError error) => Text('${error.message}'),
          _ => const CircularProgressIndicator(),
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.invalidate(helloFutureProvider),
        child: const Icon(Icons.refresh),
      ),
    );
  }
}
AsyncLoading AsyncError

ref.invalidate/ref.refresh

FutuerProviderが返す非同期処理を再実行する場合には、ref.invalidateまたはref.refreshを呼び出せばよい(DemoコードのFABでは ref.invalidateを実行している).

両者には値を返すかどうかの違いしかない:

ref.invalidate(helloFutureProvider);
final _ = await ref.refresh(helloFutureProvider.future);

ref.refresh は invalidate と read を組み合わせたシンタックスシュガーに過ぎません

再計算された後の provider の新しい値が気にならない場合は、invalidateを使用します。
新しい値が必要な場合は、代わりにrefreshを使用します。
参照: https://riverpod.dev/ja/docs/essentials/faq

T refresh<T>(provider) {
  invalidate(provider);
  return read(provider);
}

RefreshIndicator など、ユーザが操作した後で非同期処理の完了を待つUIのために、ref.refreshを採用する場合もある:

class FutureProviderDemo extends ConsumerWidget {
  const FutureProviderDemo({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final result = ref.watch(helloFutureProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Pull to refresh')),
      body: RefreshIndicator(
        onRefresh: () => ref.refresh(helloFutureProvider.future),
        child: ListView(
          children: [
            Text(result.unwrapPrevious().valueOrNull ?? ''),
          ],
        ),
      ),
    );
  }
}

参考: https://riverpod.dev/ja/docs/case_studies/pull_to_refresh

AsyncValue

FutureProvider<T> は AsyncValue<T> を返す.
したがって、AsyncValueの理解は必須である.

詳細については、下記の記事が参考になります🙏:
https://zenn.dev/tsuruo/articles/52f62fc78df6d5

Cache について

  • FutureProviderは非同期操作を実行し、その結果をキャッシュする
  • このため、FutureProviderの初期状態はAsyncLoading であるが、一度結果が得られるとAsyncLoading の状態に戻ることがない
  • invalidate/refresh を実行すると、新しい結果が得られるまではAsyncData の isLoading フラグが立つ

builder に debugPrint を仕掛けるなどして是非その遷移を確認していただきたい:

AsyncLoading<String>()
↓
↓ 3秒後
↓
AsyncData<String>(value: Hello, FutureProvider!)
↓
↓ invalidate/refresh (内部的に copyWithPrevious参照)
↓
AsyncData<String>(isLoading: true, value: Hello, FutureProvider!)
↓
↓ 3秒後
↓
AsyncData<String>(value: Hello, FutureProvider!)

単に AsyncLoading 状態の場合にローディングさせるような実装にしてしまうことによって、 invalidate/refresh してもローディング表示が出ないような問題が起こりがちである.

when から switch への置き換えについて

Before:

asyncValue.when(
  data: (value) => print(value),
  error: (error, stack) => print('Error $error'),
  loading: () => print('loading'),
);

After:

switch (asyncValue) {
  case AsyncData(:final value): print(data);
  case AsyncError(:final error): print('Error $error');
  case _: print('loading');
}

Dart 3にパターンマッチングが導入されたため、「when」や「map」に頼るのをやめて、言語固有の構文を使うべきです。

Riverpod 3 では when/map 系が Deprecatedになる可能性があるかもしれないため、Switch 文を使用した方が良さそう:
詳細: https://github.com/rrousselGit/riverpod/issues/2715

valueOrNull による参照

AsyncValue には、いくつかの便利な拡張 (AsyncValueX)がある:

エラーやローディングの状態に興味がない場合は、valueOrNull で参照するとよい:

final value = ref.watch(helloFutureProvider).valueOrNull ?? '';

Riverpod 3.0 では、valueがvalueOrNullのように動作するように変更される予定です。
今はvalueOrNullを使用し続けましょう。
参照: https://riverpod.dev/ja/docs/case_studies/pull_to_refresh

requireValue による参照

値が存在していることが保証されている場合に requireValue による参照を用いることがある:

final result = ref.watch(helloFutureProvider);
if (!result.hasValue) return const CircularProgressIndicator();
return Text(result.requireValue);

値が存在しない場合は実行時に例外が発生する.

  T get requireValue {
    if (hasValue) return value as T;
    if (hasError) {
      throwErrorWithCombinedStackTrace(error!, stackTrace!);
    }
    throw StateError(
      'Tried to call `requireValue` on an `AsyncValue` that has no value: $this',
    );
  }

値が存在していることが自明である場合を除いて使わないようにしたい.

Discussion