Start Debugging

Flutter の FutureBuilder/StreamBuilder と Riverpod の AsyncValue: どちらを使うべきか

自己完結した使い捨ての非同期ウィジェットには FutureBuilder または StreamBuilder を使います。結果が共有され、キャッシュされ、ミューテートされるようになったら Riverpod の AsyncValue に切り替えます。ここに判断基準、落とし穴、両方の実行可能なコードがあります。Flutter 3.44 と flutter_riverpod 3.3.1 で検証済みです。

Flutter 組み込みの FutureBuilder / StreamBuilder と Riverpod の AsyncValue のどちらを選ぶか迷っている場合、短い答えはこうです。使い捨ての非同期結果を所有する単一の自己完結したウィジェットにはビルダーを使い続け、その結果が画面間で共有され、キャッシュされ、リフレッシュされ、ミューテートされるようになった瞬間に Riverpod の AsyncValue に移行してください。ビルダーは同じものの「初心者版」ではありません。1 つの非同期オブジェクトを購読する UI プリミティブです。AsyncValue はウィジェットツリーの外側に存在する状態モデルです。本ガイドは Flutter 3.44 (安定版、2026-05-18)、Dart 3.12、flutter_riverpod 3.3.1 (3.0 系は 2025-09-10 にリリース) で検証しています。

それぞれ異なるレイヤーで重なり合う問題を解決します

FutureBuilderStreamBuilder はウィジェットです。それぞれに Future または Stream を渡すと、現在の接続状態 (waiting、active、done) と最新のデータまたはエラーを記述する AsyncSnapshot<T>builder コールバックに渡します。ウィジェットは挿入時に購読し、削除時に購読解除し、異なる Future/Stream インスタンスを渡すと再購読します。これが契約のすべてです。キャッシュも、共有も、ウィジェットがツリーを離れた後の結果の記憶もありません。

Riverpod の AsyncValue<T> はウィジェットではまったくありません。プロバイダーが値として公開する 3 つのサブタイプ (AsyncDataAsyncLoadingAsyncError) を持つシールドされた union です。非同期処理はウィジェットツリーの外側に存在するプロバイダー内で実行されるため、どのウィジェットからも読み取れ、複数のウィジェットが同じインスタンスを読み取れ、結果は再ビルドやナビゲーションを生き延びます。AsyncSnapshot をレンダリングするのと同じように value.when(...) または Dart 3 の switch でレンダリングしますが、真実の源はウィジェットのフィールドではなくプロバイダーです。

つまり本当の問いは「どちらが 3 つの状態をよりよくレンダリングするか」ではありません。どちらも 3 つの状態を問題なくレンダリングします。問いは、非同期結果がどこに存在すべきか、そしていくつのものがそれを見る必要があるかです。

機能マトリクス

観点FutureBuilder / StreamBuilder (Flutter 3.44)Riverpod の AsyncValue (flutter_riverpod 3.3.1)
何であるか1 つの Future/Stream を購読するウィジェットプロバイダーが公開するシールドされた状態型
結果がどこに存在するかウィジェット内、ウィジェットのアンマウントで消えるプロバイダー内、ツリーの外側、ナビゲーションを生き延びる
画面間の共有なし、各ビルダーが自身の処理を再実行あり、1 つのプロバイダーを多数のウィジェットから読み取る
キャッシュ / 重複排除なし、Future を自分でメモ化する組み込み、プロバイダーが無効化まで キャッシュ
再ビルドごとのトリガーあり、Future を build 内で作成した場合なし、プロバイダーの build は無効化まで一度実行
ローディング + 以前のデータ手動、待機中に snapshot が data を失うvalue.isLoading がリフレッシュ中に value を保持
ミューテーション / リフレッシュFuture を再代入して setStatenotifier 内の ref.invalidate または AsyncValue.guard
ウィジェットなしのテスト困難、pumpWidget が必要容易、素の ProviderContainer でプロバイダーを読み取る
依存関係ゼロ、SDK に同梱flutter_riverpod パッケージ
1 回限りのボイラープレート行数最小限1 つの使い捨て呼び出しにより多くのセットアップ

FutureBuilder または StreamBuilder が正しい選択である場合

非同期結果が本当に 1 つのウィジェットに属し、他の誰もそれを必要としない場合は、組み込みのビルダーを使ってください。

正しい形がこちらです。Futurebuild ではなく initState で一度だけ作成されるため、ウィジェットは親の再ビルドごとに再フェッチしません。

// Flutter 3.44, Dart 3.12
class UserCard extends StatefulWidget {
  const UserCard({super.key, required this.id});
  final String id;

  @override
  State<UserCard> createState() => _UserCardState();
}

class _UserCardState extends State<UserCard> {
  late Future<User> _user;

  @override
  void initState() {
    super.initState();
    _user = api.fetchUser(widget.id); // created ONCE, not in build
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<User>(
      future: _user,
      builder: (context, snapshot) {
        return switch (snapshot) {
          AsyncSnapshot(connectionState: ConnectionState.waiting) =>
            const CircularProgressIndicator(),
          AsyncSnapshot(hasError: true, :final error) =>
            Text('Failed: $error'),
          AsyncSnapshot(hasData: true, :final data?) =>
            Text(data.name),
          _ => const SizedBox.shrink(),
        };
      },
    );
  }
}

このウィジェットで最もよくあるバグは、future: api.fetchUser(widget.id) のように Futurebuild 内でインラインに作成することです。すると再ビルドのたびに新しい Future を割り当て、FutureBuilder は新しいアイデンティティを見てローディング状態から再開します。この失敗モードは独自の記事を持つほど一般的です。完全な再現とそれをトリガーするすべてのバリアントについては なぜ FutureBuilder は再ビルドごとに Future を再作成するのか を参照してください。

Riverpod の AsyncValue が正しい選択である場合

非同期結果が 1 つのウィジェットのプライベートな詳細でなくなったら AsyncValue に移行してください。

同じ 3 状態のレンダリングを、今度はプロバイダーから取得します。

// Flutter 3.44, Dart 3.12, flutter_riverpod 3.3.1
final userProvider = FutureProvider.family<User, String>((ref, id) {
  return api.fetchUser(id); // runs once, cached per id, shared everywhere
});

class UserCard extends ConsumerWidget {
  const UserCard({super.key, required this.id});
  final String id;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProvider(id));
    return switch (user) {
      AsyncData(:final value) => Text(value.name),
      AsyncError(:final error) => Text('Failed: $error'),
      _ => const CircularProgressIndicator(),
    };
  }
}

ref.watch(userProvider('42')) を呼ぶ 2 つのウィジェットは 1 つのフェッチと 1 つのキャッシュされた結果を共有します。プロバイダーは無効化されるまで引数ごとに build をちょうど一度実行するため、initState も、保存フィールドも、覚えておくべき「Future を一度だけ作成する」という規律もありません。状態の完全なセット、AsyncValue.guard でのミューテーション、リフレッシュ時の以前のデータの保持については AsyncValue でローディングとエラーの状態を表示する方法 を参照してください。

実際に決め手となる再ビルドと再フェッチの挙動

ここでパフォーマンスは軸ではありません。どちらのアプローチも同じフレームレートでレンダリングします。異なるのは非同期処理が何回実行されるかであり、これは生の速度ではなく正しさとコストの問題です。

非同期呼び出しの中にカウンターを置き、周囲のウィジェットが再ビルドされるとき (テーマの切り替え、キーボードが開く、親の setState) に何が起こるかを観察してください。

非同期処理が安価なローカル読み取りであれば、これらは何も問題にならず、ビルダーが単純さで勝ちます。ネットワーク呼び出し、データベースクエリ、あるいはコストやレート制限のある何かであれば、キャッシュこそが AsyncValue が存在する理由そのものであり、同じ挙動を FutureBuilder の周りで手作業で再実装するのは Riverpod のプロバイダーキャッシュの劣化版を再実装することになります。

あなたの代わりに決めてしまう落とし穴

いくつかの制約は好みに関係なく判断を決めてしまいます。

すでに Riverpod を使っている。 アプリにプロバイダーがあるなら、それらを読む画面に FutureBuilder を混ぜないでください。プロバイダーのデータを読み、その後別の非同期呼び出しを 2 つ目の FutureBuilder で包むと、1 つの画面に無関係な 2 つのライフサイクルと、「loading」が true になりうる 2 か所が生まれます。2 つ目の呼び出しもプロバイダーとして公開し、両方を AsyncValue でレンダリングしてください。ここでの一貫性は、画面の半分が古くなるクラスのバグを防ぎます。

結果がウィジェットより長生きする必要がある。 initState でフェッチされたものはすべて State とともに消えます。ユーザーが前に進んで戻り、毎回新しいスピナーと新しいネットワーク呼び出しを望まないなら、ウィジェットの上に存在するキャッシュが必要です。それがプロバイダーです。FutureBuilder はどう配置してもルート間の永続性を与えられません。

await の後で ref に触れる。 これは Riverpod 固有の罠であり、避ける理由ではありません。notifier 内で await し、それをトリガーしたウィジェットが消えた後に ref を読むと、Cannot use "ref" after the widget was disposed に遭遇します。修正は await の前に必要なものを取得することです。採用する前に知っておく価値があり、破棄後に ref を使う問題の修正 で扱っています。

明示的に依存関係ゼロを望む。 pub パッケージのサンプル、再現ケース、または状態管理ライブラリに反対するチームポリシーはビルダーを強制します。これは正当な制約であり、ビルダーは自己完結した非同期 UI には十分に有能です。

StreamBuilder にはもう 1 つの注意点があります

上記すべては Future の処理に当てはまります。ストリームは購読のライフサイクルを追加し、それは非自明なものすべてについて判断をもう少し Riverpod 寄りに傾けます。StreamBuilder は新しい Stream インスタンスを渡すと再購読し、ツリーを離れると購読解除しますが、マルチキャストはしません。同じ単一購読ストリームに対する 2 つの StreamBuilder はエラーをスローします。単一購読の Stream は 1 つのリスナーしか許さないからです。Riverpod の StreamProvider はストリームの前に位置するため、複数のウィジェットが購読を奪い合うことなく 1 つの AsyncValue を読み、最新の値は遅れて来た購読者のためにキャッシュされます。ストリームがちょうど 1 か所で表示されるなら StreamBuilder で問題ありません。複数のウィジェットがそれを必要とするなら、StreamProvider が単一リスナーの問題を完全に取り除きます。

背後の完全な文脈とともに、推奨事項

共有され、キャッシュされ、リフレッシュされ、ミューテートされる非同期結果には、デフォルトで Riverpod の AsyncValue を使ってください。実際のアプリではそのほとんどがそうです。N 回ではなく 1 回のフェッチ、ナビゲーションをまたぐ無料のキャッシュ、リフレッシュ時に以前のデータを保持する isLoading、そしてウィジェットなしでテストできるロジックが得られます。FutureBuilderStreamBuilder は、本当に自己完結した使い捨ての非同期 UI のために取っておいてください。1 つのものを読み込み、表示し、アンマウントで忘れるリーフウィジェット、特に状態管理の依存関係を持たないアプリでです。ビルダーは卒業する補助輪ではありません。非同期結果の観客が 1 人のときには正しいツールであり、観客が 2 人になった瞬間に間違ったツールになります。慣れ親しみではなく所有によって選んでください。

より広く状態管理のアプローチをまだ選んでいる場合、パッケージ間のトレードオフは 2026 年の Flutter 状態管理における Provider と Riverpod と Bloc にあります。そして非同期 UI が失敗を露呈し続けるなら、Flutter アプリでネットワークエラーを優雅に処理する方法 が、スローされた例外を両方のモデルでクリーンなエラー状態に変える方法を扱っています。

出典

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る