【Flutter】riverpodで複数のAsyncValueを適切に扱う実装パターン集
はじめに
Flutterアプリケーションの状態管理において、非同期処理の結果を安全かつ効率的に扱う手段として riverpod
の AsyncValue
は非常に有用です。
非同期の状態(ローディング中・成功・失敗)を明示的に区別できるため、状態に応じたUIの切り替えがシンプルに実装できます。
しかし実際の開発では、1つの画面内で複数の AsyncValue
を扱うことが多くなります。
このとき、どのようにハンドリングすれば最も自然で保守しやすいか? は悩みどころです。
特に以下のようなケースに直面することがあります:
- 表示できるものから順に表示したい
- すべてのデータが揃うまで待ちたい
- 最低限の初期表示をしておいて、あとから非同期で上書きしたい
これらのユースケースに応じて、AsyncValue
の扱い方にも工夫が求められます。
本記事では、以下のような3つの実装パターンを紹介し、それぞれのメリット・デメリットを解説していきます:
-
AsyncValue
ごとに個別にハンドリングする方法 - 複数の
AsyncValue
を1つの Provider に統合して扱う方法 -
valueOrNull
を用いて暫定値でUIを構築する方法
実際のコード例や比較を通じて、それぞれの使いどころを掴んでいただければと思います。
記事の対象者
- Riverpodを使って状態管理を行っているFlutter開発者
-
AsyncValue
を複数扱う画面において、どのようにハンドリングすべきか迷っている方 -
AsyncValue.when
やswitch
の分岐処理を毎回書くのが煩雑に感じている方 -
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)
ソースコード
ソースコードの概要
このプロジェクトには3つの非同期な状態をproviderで定義してます。
それぞれは非同期を模倣するために一定の秒数で決まった MaterialColor
を返すようにしています。
Future<MaterialColor> red1Seconds(Ref ref) async {
await Future<void>.delayed(const Duration(seconds: 1));
return Colors.red;
}
Future<MaterialColor> blue3Seconds(Ref ref) async {
await Future<void>.delayed(const Duration(seconds: 3));
return Colors.blue;
}
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
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
単位でハンドリングする
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
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
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が表示できない
AsyncValue
を束ねた Provider
複数の 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
の場合に限ります。
依存関係があると内部で処理が壊れてしまうのでそこもまた注意が必要です。
型エイリアスとレコード型については以下の記事で解説していますので、よろしければご覧ください。
画面で一括でハンドリングする
画面全体で一つの Provider
を watch
すればいいので実装はシンプルです。
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を構築する方法
AsyncValue
の valueOrNull
を使うと以下のような挙動を実現できます。
- 値がとれていない場合はデフォルト値を使って暫定の値でUIを表示する
- ローディングやエラーなどのハンドリングはしない
valueOrNull
とは?
AsyncValue
には値を取得する際に使用できるゲッターとして valueOrNull
が用意されています。
ひとまずざっくりの理解としては値がなければnullを返す、となります。
細かくみていくと次のとおりです。
値の取得を試みた際に以下の場合はnullを返します。
- 初回取得でまだ値がなかった場合
- ローディング中で値がなかった場合
- 値取得に失敗してエラーを返した場合
以下の場合は値を返しますが、ちょっと複雑です。
- 値があれば値を返す
- 2回目の取得でローディングになった場合は直前に取れている値を返す
そして、nullを返すので当然デフォルト値を設定することも可能です。
この仕様をうまく使うと、AsyncValue
を switch式や .when
でハンドリングするこを省略することができます。
今回はその中で以下の2パターンをご紹介します。
ビルドの中で定義するパターン
こちらは基本的な利用方法です。
以下では red1SecondsProvider
に限定してみていきましょう。
まずはいつも通りビルドの中で Provider
を watch
します。
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
でハンドリングせずにシンプルに書けます。
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ツリーの可読性が大幅に向上します。
また、テストの観点からも状態のロジックが関数として独立しているメリットがあります。
いわゆるカスタムフックにします。
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側では以下のようにシンプルに書くことができます。
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,
),
],
),
),
);
}
}
尚、カスタムフックの活用例は他にもあり、以下の記事でも解説していますので気になる方はぜひご覧ください。
part
と part of
を使ったファイル分割方法の詳細は以下の記事をご覧ください。
終わりに
Flutter + riverpod において AsyncValue
をどのように UI でハンドリングするかは、アプリケーションの特性や要件によって最適解が異なります。
本記事では以下の3つの代表的なパターンを紹介しました。
- 各
AsyncValue
を個別にハンドリングする(Widget単位 / 全体でwatchしてswitch) - 複数の
AsyncValue
を1つの Provider にまとめてハンドリングする -
valueOrNull
を使って初期値を持たせ、switchやwhenを使わず簡潔に書く
それぞれのパターンには明確なメリット・デメリットがあり、状況に応じて使い分けることが重要です。
例えば、「すぐに表示できるものから順に出したい」なら個別ハンドリングが良く、「すべて揃ってから描画したい」ならまとめるパターンが適しています。
また、表示だけで済むような簡単なデータであれば valueOrNull
を活用することでコードの簡潔さと柔軟性を両立できます。
ぜひご自身のプロジェクトで、この記事で紹介した実装パターンを試していただき、自分なりのベストプラクティスを見つけていただければ幸いです。
Discussion