🔀

【Flutter】riverpodで複数のAsyncValueを適切に扱う実装パターン集

に公開

はじめに

Flutterアプリケーションの状態管理において、非同期処理の結果を安全かつ効率的に扱う手段として riverpodAsyncValue は非常に有用です。

https://riverpod.dev

非同期の状態(ローディング中・成功・失敗)を明示的に区別できるため、状態に応じたUIの切り替えがシンプルに実装できます。

しかし実際の開発では、1つの画面内で複数の AsyncValue を扱うことが多くなります。
このとき、どのようにハンドリングすれば最も自然で保守しやすいか? は悩みどころです。
特に以下のようなケースに直面することがあります:

  • 表示できるものから順に表示したい
  • すべてのデータが揃うまで待ちたい
  • 最低限の初期表示をしておいて、あとから非同期で上書きしたい

これらのユースケースに応じて、AsyncValue の扱い方にも工夫が求められます。

本記事では、以下のような3つの実装パターンを紹介し、それぞれのメリット・デメリットを解説していきます:

  • AsyncValue ごとに個別にハンドリングする方法
  • 複数の AsyncValue を1つの Provider に統合して扱う方法
  • valueOrNull を用いて暫定値でUIを構築する方法

実際のコード例や比較を通じて、それぞれの使いどころを掴んでいただければと思います。

記事の対象者

  • Riverpodを使って状態管理を行っているFlutter開発者
  • AsyncValue を複数扱う画面において、どのようにハンドリングすべきか迷っている方
  • AsyncValue.whenswitch の分岐処理を毎回書くのが煩雑に感じている方
  • valueOrNull を活用した柔軟なUI構築に興味がある方
  • 初学者〜中級者で、非同期データとUIの関係性を整理したいと考えている方

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.29.0, on macOS
    15.3.1 24D70 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android
    devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.100.0)

ソースコード

https://github.com/HaruhikoMotokawa/riverpod_watch_many_sample/tree/main

ソースコードの概要

このプロジェクトには3つの非同期な状態をproviderで定義してます。
それぞれは非同期を模倣するために一定の秒数で決まった MaterialColor を返すようにしています。

lib/presentation/state/red_1_seconds.dart

Future<MaterialColor> red1Seconds(Ref ref) async {
  await Future<void>.delayed(const Duration(seconds: 1));
  return Colors.red;
}
lib/presentation/state/blue_3_seconds.dart

Future<MaterialColor> blue3Seconds(Ref ref) async {
  await Future<void>.delayed(const Duration(seconds: 3));
  return Colors.blue;
}
lib/presentation/state/yellow_5_seconds.dart

Future<MaterialColor> yellow5Seconds(Ref ref) async {
  await Future<void>.delayed(const Duration(seconds: 5));
  return Colors.yellow;
}

上記の3つの状態を画面で watch して取得します。
画面ごとに異なるハンドリングで表示しています。

取得した MaterialColor を使って表示する Widget は共通で定義したコンポーネントを使用します。
以下の内容で表示されます。

  • ListTile の背景色を受け取った値の色に装飾する
  • ListTile のタイトルに受け取った色を文字列で表示する
  • ListTile のサブタイトルに受け取った値を取得するためにかかる秒数を表示する
  • ListTile をタップした場合、受け取った値の色をテキストにしてスナックバーで表示する
ColorListTile
lib/presentation/shared/color_list_tile.dart
class ColorListTile extends StatelessWidget {
  const ColorListTile({
    required this.color,
    this.enabled = true,
    super.key,
  });

  final MaterialColor color;
  final bool enabled;

  String get _colorName => switch (color) {
        Colors.red => 'Red',
        Colors.blue => 'Blue',
        Colors.yellow => 'Yellow',
        _ => 'Unknown Color',
      };

  String get _subtitle => switch (color) {
        Colors.red => '取得までに1秒かかります',
        Colors.blue => '取得までに3秒かかります',
        Colors.yellow => '取得までに5秒かかります',
        _ => 'unSupported Color',
      };

  
  Widget build(BuildContext context) {
    return ListTile(
      enabled: enabled,
      leading: const Icon(Icons.color_lens),
      title: Text('Color: $_colorName'),
      subtitle: Text(_subtitle),
      onTap: () {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('Color: $_colorName'),
            duration: const Duration(milliseconds: 500),
          ),
        );
      },
      tileColor: color,
    );
  }
}

AsyncValue ごとに個別にハンドリングする方法

このパターンはそれぞれの状態をそれぞれにハンドリングするパターンです。
その中でも細かく分けると2つのパターンが存在します。

  • Widget ごとに watch してハンドリングする
  • 画面で watch して Widget 単位でハンドリングする

https://youtu.be/OLYbsXfOee4

Widget ごとに watch してハンドリングするパターン

それぞれ表示する Widget の領域を Consumer でラップして個別に watch するパターンです。

// 一部抜粋
Consumer(builder: (context, ref, child) {
  final asyncRed = ref.watch(red1SecondsProvider);

  return switch (asyncRed) {
    AsyncData(value: final red) => ColorListTile(color: red),
    AsyncError() => const Text('Error occurred while fetching color'),
    _ => const Padding(
        padding: EdgeInsets.symmetric(vertical: 2),
        child: CircularProgressIndicator(),
      ),
  };
}),

メリット

  • 個別の watch により画面のリビルドを抑えられる
  • 各値ごとに細かくハンドリングできる
  • 値がとれたものはすぐに表示できる

デメリット

  • コード量が多くなる
  • コード量に比べて実はリビルド低減の恩恵は多くないかもしれない
SplitWatchScreen
lib/presentation/screens/split_watch/screen.dart
class SplitWatchScreen extends StatelessWidget {
  const SplitWatchScreen({super.key});

  static const String path = '/split_watch';
  static const String name = 'SplitWatchScreen';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text(name),
      ),
      body: Center(
          child: Column(
        spacing: 20,
        children: [
          Consumer(builder: (context, ref, child) {
            final asyncRed = ref.watch(red1SecondsProvider);

            return switch (asyncRed) {
              AsyncData(value: final red) => ColorListTile(color: red),
              AsyncError() => const Text('Error occurred while fetching color'),
              _ => const Padding(
                  padding: EdgeInsets.symmetric(vertical: 2),
                  child: CircularProgressIndicator(),
                ),
            };
          }),
          Consumer(builder: (context, ref, child) {
            final asyncBlue = ref.watch(blue3SecondsProvider);

            return switch (asyncBlue) {
              AsyncData(value: final blue) => ColorListTile(color: blue),
              AsyncError() => const Text('Error occurred while fetching color'),
              _ => const Padding(
                  padding: EdgeInsets.symmetric(vertical: 2),
                  child: CircularProgressIndicator(),
                ),
            };
          }),
          Consumer(builder: (context, ref, child) {
            final asyncYellow = ref.watch(yellow5SecondsProvider);

            return switch (asyncYellow) {
              AsyncData(value: final yellow) => ColorListTile(color: yellow),
              AsyncError() => const Text('Error occurred while fetching color'),
              _ => const Padding(
                  padding: EdgeInsets.symmetric(vertical: 2),
                  child: CircularProgressIndicator(),
                ),
            };
          }),
        ],
      )),
    );
  }
}

画面で watch して Widget 単位でハンドリングするパターン

こちらは watch は画面の全体のビルド内で行うものの、それぞれのハンドリングは個別に行うパターンです。

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncRed = ref.watch(red1SecondsProvider);
    final asyncBlue = ref.watch(blue3SecondsProvider);
    final asyncYellow = ref.watch(yellow5SecondsProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text(name),
      ),
      body: Center(
        child: Column(
          spacing: 20,
          children: [
            switch (asyncRed) {
              AsyncData(value: final red) => ColorListTile(color: red),
              AsyncError() =>
                const Text('Error occurred while fetching red color'),
              _ => const Padding(
                  padding: EdgeInsets.symmetric(vertical: 2),
                  child: CircularProgressIndicator(),
                ),
            },
    // ...
  }

メリット

  • Consumer でラップする手間が省ける
  • 各値ごとに細かくハンドリングできる
  • 値がとれたものはすぐに表示できる

デメリット

  • コード量は多い
  • AsyncValue の更新頻度によってはパフォーマンスの懸念がある
SingleWatchSplitHandleScreen
lib/presentation/screens/single_watch_split_handle/screen.dart
class SingleWatchSplitHandleScreen extends ConsumerWidget {
  const SingleWatchSplitHandleScreen({super.key});

  static const String path = '/single_watch_split_handle';
  static const String name = 'SingleWatchSplitHandleScreen';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncRed = ref.watch(red1SecondsProvider);
    final asyncBlue = ref.watch(blue3SecondsProvider);
    final asyncYellow = ref.watch(yellow5SecondsProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text(name),
      ),
      body: Center(
        child: Column(
          spacing: 20,
          children: [
            switch (asyncRed) {
              AsyncData(value: final red) => ColorListTile(color: red),
              AsyncError() =>
                const Text('Error occurred while fetching red color'),
              _ => const Padding(
                  padding: EdgeInsets.symmetric(vertical: 2),
                  child: CircularProgressIndicator(),
                ),
            },
            switch (asyncBlue) {
              AsyncData(value: final blue) => ColorListTile(color: blue),
              AsyncError() =>
                const Text('Error occurred while fetching blue color'),
              _ => const Padding(
                  padding: EdgeInsets.symmetric(vertical: 2),
                  child: CircularProgressIndicator(),
                ),
            },
            switch (asyncYellow) {
              AsyncData(value: final yellow) => ColorListTile(color: yellow),
              AsyncError() =>
                const Text('Error occurred while fetching yellow color'),
              _ => const Padding(
                  padding: EdgeInsets.symmetric(vertical: 2),
                  child: CircularProgressIndicator(),
                ),
            },
          ],
        ),
      ),
    );
  }
}

複数の AsyncValue を1つの Provider に統合して扱う方法

全ての値が取得できた完全な状態でUIを表示したい場合、このパターンが有用です。

メリット

  • ハンドリング対象が一つなのでUI側がシンプル

デメリット

  • 新たにProviderを定義する必要がある
  • Providerの作り方には工夫が必要で多少複雑である
  • 全ての値が取得できないとUIが表示できない

https://youtu.be/ciibavU93qU

複数の AsyncValue を束ねた Provider

lib/presentation/screens/combined_provider/provider.dart
typedef CombinedProviderScreenState = ({
  MaterialColor red,
  MaterialColor blue,
  MaterialColor yellow,
});


Future<CombinedProviderScreenState> combinedProviderScreenState(Ref ref) async {
  final (red, blue, yellow) = await (
    ref.watch(red1SecondsProvider.future),
    ref.watch(blue3SecondsProvider.future),
    ref.watch(yellow5SecondsProvider.future),
  ).wait;

  return (
    red: red,
    blue: blue,
    yellow: yellow,
  );
}

ここで注意したいのは可能であれば上記のように await (...).wait で並行処理を行わないと待機時間がかかってしまうということです。

例えば以下のようにすると、

final red = await ref.watch(red1SecondsProvider.future);
final blue = await ref.watch(blue3SecondsProvider.future);
final yellow = await ref.watch(yellow5SecondsProvider.future);

red取得に1秒 -> blue取得に3秒 -> yellow取得に5秒 == 9秒
となり、余計に時間がかかってしまいます。

ただ、この await (...).wait はお互いが依存していない AsyncValue の場合に限ります。
依存関係があると内部で処理が壊れてしまうのでそこもまた注意が必要です。

型エイリアスとレコード型については以下の記事で解説していますので、よろしければご覧ください。

https://zenn.dev/harx/articles/d766f243639258

画面で一括でハンドリングする

画面全体で一つの Providerwatch すればいいので実装はシンプルです。

lib/presentation/screens/combined_provider/screen.dart
class CombinedProviderScreen extends ConsumerWidget {
  const CombinedProviderScreen({super.key});

  static const String path = '/combined_provider';
  static const String name = 'CombinedProviderScreen';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncState = ref.watch(combinedProviderScreenStateProvider);

    return Scaffold(
        appBar: AppBar(
          title: const Text(name),
        ),
        body: Center(
          child: switch (asyncState) {
            AsyncData(value: final state) => Column(
                spacing: 20,
                children: [
                  ColorListTile(color: state.red),
                  ColorListTile(color: state.blue),
                  ColorListTile(color: state.yellow),
                ],
              ),
            AsyncError() => const Text('Error occurred while fetching data'),
            _ => const CircularProgressIndicator(),
          },
        ));
  }
}

valueOrNull を用いて暫定値でUIを構築する方法

AsyncValuevalueOrNull を使うと以下のような挙動を実現できます。

  • 値がとれていない場合はデフォルト値を使って暫定の値でUIを表示する
  • ローディングやエラーなどのハンドリングはしない

https://youtu.be/faGes3PxxyU

valueOrNull とは?

AsyncValue には値を取得する際に使用できるゲッターとして valueOrNull が用意されています。
ひとまずざっくりの理解としては値がなければnullを返す、となります。

細かくみていくと次のとおりです。

値の取得を試みた際に以下の場合はnullを返します。

  • 初回取得でまだ値がなかった場合
  • ローディング中で値がなかった場合
  • 値取得に失敗してエラーを返した場合

以下の場合は値を返しますが、ちょっと複雑です。

  • 値があれば値を返す
  • 2回目の取得でローディングになった場合は直前に取れている値を返す

そして、nullを返すので当然デフォルト値を設定することも可能です。
この仕様をうまく使うと、AsyncValue を switch式や .when でハンドリングするこを省略することができます。

今回はその中で以下の2パターンをご紹介します。

ビルドの中で定義するパターン

こちらは基本的な利用方法です。
以下では red1SecondsProvider に限定してみていきましょう。
まずはいつも通りビルドの中で Providerwatch します。


Widget build(BuildContext context, WidgetRef ref) {
  final asyncRed = ref.watch(red1SecondsProvider);
  // ....
}

次に valueOrNull を使って値を取得しますが、この時同時にデフォルト値も設定しておきます。

final red = asyncRed.valueOrNull ?? Colors.grey;

今回の ColorListTile はちゃんとした値がとれていない場合はタップできないようにしたいので、asyncRed の状態によってハンドリングします。

final isRedTileEnabled = !asyncRed.isLoading && !asyncRed.hasError;

全体は以下のようになります。 return 以下はswitch式や .when でハンドリングせずにシンプルに書けます。

lib/presentation/screens/value_or_default/screen.dart
class ValueOrDefaultScreen extends ConsumerWidget {
  const ValueOrDefaultScreen({super.key});

  static const String path = '/value_or_default';
  static const String name = 'ValueOrDefaultScreen';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncRed = ref.watch(red1SecondsProvider);
    final asyncBlue = ref.watch(blue3SecondsProvider);
    final asyncYellow = ref.watch(yellow5SecondsProvider);

    final red = asyncRed.valueOrNull ?? Colors.grey;
    final blue = asyncBlue.valueOrNull ?? Colors.grey;
    final yellow = asyncYellow.valueOrNull ?? Colors.grey;

    final isRedTileEnabled = !asyncRed.isLoading && !asyncRed.hasError;
    final isBlueTileEnabled = !asyncBlue.isLoading && !asyncBlue.hasError;
    final isYellowTileEnabled = !asyncYellow.isLoading && !asyncYellow.hasError;

    return Scaffold(
        appBar: AppBar(
          title: const Text(name),
        ),
        body: Center(
          child: Column(
            spacing: 20,
            children: [
              ColorListTile(
                color: red,
                enabled: isRedTileEnabled,
              ),
              ColorListTile(
                color: blue,
                enabled: isBlueTileEnabled,
              ),
              ColorListTile(
                color: yellow,
                enabled: isYellowTileEnabled,
              ),
            ],
          ),
        ));
  }
}

カスタムフックで valueOrNull などの値取得をまとめる

先ほどのビルドに定義する場合は return 以下の Widget 部分はシンプルに書けたました。
反面、ビルド内の return に至るまでのコード量が増えてしまい可読性が悪くなってしまいました。

そこでUIロジックを関数に切り出すことで、Widgetツリーの可読性が大幅に向上します。
また、テストの観点からも状態のロジックが関数として独立しているメリットがあります。
いわゆるカスタムフックにします。

lib/presentation/screens/custom_hook/_use_screen_state.dart
part of 'screen.dart';

typedef _ScreenState = ({
  MaterialColor red,
  MaterialColor blue,
  MaterialColor yellow,
  bool isRedTileEnabled,
  bool isBlueTileEnabled,
  bool isYellowTileEnabled,
});

_ScreenState _useScreenState(WidgetRef ref) {
  // -------------------- //
  // providerの取得
  // -------------------- //
  final asyncRed = ref.watch(red1SecondsProvider);
  final asyncBlue = ref.watch(blue3SecondsProvider);
  final asyncYellow = ref.watch(yellow5SecondsProvider);

  // -------------------- //
  // AsyncValueから値を取得
  // -------------------- //
  final red = asyncRed.valueOrNull ?? Colors.grey;
  final blue = asyncBlue.valueOrNull ?? Colors.grey;
  final yellow = asyncYellow.valueOrNull ?? Colors.grey;

  final isRedTileEnabled = !asyncRed.isLoading && !asyncRed.hasError;
  final isBlueTileEnabled = !asyncBlue.isLoading && !asyncBlue.hasError;
  final isYellowTileEnabled = !asyncYellow.isLoading && !asyncYellow.hasError;

  // -------------------- //
  // 取得した値を返す
  // -------------------- //
  return (
    red: red,
    blue: blue,
    yellow: yellow,
    isRedTileEnabled: isRedTileEnabled,
    isBlueTileEnabled: isBlueTileEnabled,
    isYellowTileEnabled: isYellowTileEnabled,
  );
}

戻り値はプライベートな型エイリアスなレコード型とし、_useScreenState のなかで最初にご紹介したビルド内で定義したものをそのまま定義し、その結果を戻り値に入れるだけです。

メソッドも戻り値もファイルプライベートにしたかったので、partで分割したファイルに定義しています。
上記のようにしてカスタムフック内に取得の過程を隠蔽した結果、UI側では以下のようにシンプルに書くことができます。

lib/presentation/screens/custom_hook/screen.dart
part '_use_screen_state.dart';

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

  static const String path = '/custom_hook';
  static const String name = 'CustomHookScreen';

  
  Widget build(BuildContext context, WidgetRef ref) {
    final screenState = _useScreenState(ref);

    return Scaffold(
      appBar: AppBar(
        title: const Text(name),
      ),
      body: Center(
        child: Column(
          spacing: 20,
          children: [
            ColorListTile(
              color: screenState.red,
              enabled: screenState.isRedTileEnabled,
            ),
            ColorListTile(
              color: screenState.blue,
              enabled: screenState.isBlueTileEnabled,
            ),
            ColorListTile(
              color: screenState.yellow,
              enabled: screenState.isYellowTileEnabled,
            ),
          ],
        ),
      ),
    );
  }
}

尚、カスタムフックの活用例は他にもあり、以下の記事でも解説していますので気になる方はぜひご覧ください。

https://zenn.dev/harx/articles/f83364d92a1b0b

partpart of を使ったファイル分割方法の詳細は以下の記事をご覧ください。

https://zenn.dev/harx/articles/09d569d011bb4f

終わりに

Flutter + riverpod において AsyncValue をどのように UI でハンドリングするかは、アプリケーションの特性や要件によって最適解が異なります。

本記事では以下の3つの代表的なパターンを紹介しました。

  • AsyncValue を個別にハンドリングする(Widget単位 / 全体でwatchしてswitch)
  • 複数の AsyncValue を1つの Provider にまとめてハンドリングする
  • valueOrNull を使って初期値を持たせ、switchやwhenを使わず簡潔に書く

それぞれのパターンには明確なメリット・デメリットがあり、状況に応じて使い分けることが重要です。
例えば、「すぐに表示できるものから順に出したい」なら個別ハンドリングが良く、「すべて揃ってから描画したい」ならまとめるパターンが適しています。
また、表示だけで済むような簡単なデータであれば valueOrNull を活用することでコードの簡潔さと柔軟性を両立できます。

ぜひご自身のプロジェクトで、この記事で紹介した実装パターンを試していただき、自分なりのベストプラクティスを見つけていただければ幸いです。

Discussion