公開日 2026-07-17

Flutterでローディング・空状態・エラー表示を整える

Flutterで FutureProvider と AsyncValue を使い、ローディング・空状態・エラー表示を分けながら再試行と共通 UI 化の入口まで確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. なぜ 0 件と失敗を分けるのか
  6. 3. プロジェクトを作成し、Riverpod を追加する
  7. 3-1. 環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. flutter_riverpod を追加する
  11. 4. lib/main.dart を作成して 3 状態を表示する
  12. コードのポイント
  13. 5. flutter run で各状態を確認する
  14. 5-1. ローディング表示を確認する
  15. 5-2. 空状態を確認する
  16. 5-3. エラー表示と再試行を確認する
  17. 6. 共通 UI と後続記事へのつなぎ方
  18. 7. まとめ

FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) で通信の入口を作り、Flutterの状態管理入門(Riverpod最小構成) で状態の置き場を分けたら、次に揃えたいのが非同期画面の見せ方です。止まりやすいのは API を呼ぶ処理そのものではありません。読み込み中、0 件、失敗をどう区別して見せるかが曖昧なまま画面を増やし始めるところです。この記事では flutter_riverpodFutureProviderAsyncValue を使い、ローディング表示、空状態、エラー表示、再試行、共通 UI 化の入口までを 1 画面で確認します。

1. ゴールと非対象

対象読者

  • Flutter の REST API 通信と Riverpod の最小導入までは終わっている人
  • 一覧画面を作り始めたが、0 件と失敗が同じ見え方になりやすい人
  • 今後のフォーム、一覧検索、CRUD、認証記事へ進む前に、非同期画面の基本形を揃えたい人

この記事で到達する状態

  • FutureProvider で非同期の一覧取得を表現できる
  • AsyncValueloading / error / data を UI へ分けて表示できる
  • data.isEmpty を別分岐として扱い、空状態をエラーと混同しない
  • ref.invalidate(shipmentsProvider) で再試行できる
  • ローディング・空状態・エラー表示を AsyncValuePane<T> へ寄せる入口を作れる

非対象

  • 実際の HTTP 通信や dio への移行
  • 認証ガードやトークン失効時の分岐
  • sealed class とパターンマッチング
  • ページング、無限スクロール、検索条件保持
  • Riverpod Generator や freezed

今回は UI 状態の整理に絞ります。通信の作り方は REST API 記事で扱っているため、ここでは成功 / 0 件 / 失敗を切り替えられる擬似 Repository を使い、画面の受け止め方だけを見ます。

2. なぜ 0 件と失敗を分けるのか

AsyncValue を使うと、Riverpod 側では次の 3 状態を 1 つの型で扱えます。

flowchart LR
  A[状態切替 UI] --> B[selectedScenarioProvider]
  B --> C[shipmentsProvider]
  C -->|取得中| D[loading]
  C -->|例外発生| E[error]
  C -->|取得成功| F[data]
  F -->|0件| G[empty state]
  F -->|1件以上| H[list UI]
  E --> I[再試行ボタン]
  I --> C

ここで分けたいのは 2 つです。

  • 取得に失敗したのか
  • 取得には成功したが結果が 0 件なのか

この 2 つは、読者にも利用者にも意味が違います。失敗なら再試行や接続確認を促す必要があります。0 件なら検索条件を見直す、まだ登録されていない、といった案内のほうが伝わりやすくなります。AsyncValuedata に空リストが入るケースは error ではないため、UI 側で isEmpty を別に見る必要があります。

3. プロジェクトを作成し、Riverpod を追加する

3-1. 環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

3-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成します。

flutter create my_async_state_app
cd my_async_state_app

3-3. エミュレーターを起動する

利用可能なエミュレーター一覧を確認します。

flutter emulators

表示された ID を指定して起動します。

flutter emulators --launch <emulator_id>

3-4. flutter_riverpod を追加する

プロジェクト直下で次のコマンドを実行します。

flutter pub add flutter_riverpod

この記事のサンプルコードは Riverpod 3.x 以上を前提にしています。以降は flutter run で確認できます。

4. lib/main.dart を作成して 3 状態を表示する

この lib/main.dart は、loading、empty、error を AsyncValue で切り替えつつ、表示ルールを共通部品へ寄せるサンプルです。どこが状態の入口で、どこが見た目の共通化ポイントかに注目してください。

lib/main.dart は次の内容に書き換えます。

import 'dart:async';

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

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

enum LoadScenario {
  success('正常データ'),
  empty('0件'),
  error('失敗');

  const LoadScenario(this.label);

  final String label;
}

class Shipment {
  const Shipment({
    required this.id,
    required this.code,
    required this.customer,
    required this.status,
  });

  final int id;
  final String code;
  final String customer;
  final String status;
}

class ShipmentRepository {
  static const List<Shipment> _mockShipments = <Shipment>[
    Shipment(
      id: 1,
      code: 'S-1001',
      customer: '東京商事',
      status: '未出荷',
    ),
    Shipment(
      id: 2,
      code: 'S-1002',
      customer: '大阪物産',
      status: 'ピッキング中',
    ),
    Shipment(
      id: 3,
      code: 'S-1003',
      customer: '名古屋販売',
      status: '確認待ち',
    ),
  ];

  Future<List<Shipment>> fetchShipments(LoadScenario scenario) async {
    await Future<void>.delayed(const Duration(seconds: 2));

    switch (scenario) {
      case LoadScenario.success:
        return _mockShipments;
      case LoadScenario.empty:
        return const <Shipment>[];
      case LoadScenario.error:
        throw Exception(
          '出荷一覧を取得できませんでした。ネットワーク接続かサーバー状態を確認してください。',
        );
    }
  }
}

final Provider<ShipmentRepository> shipmentRepositoryProvider =
    Provider<ShipmentRepository>((Ref ref) {
      return ShipmentRepository();
    });

class SelectedScenarioNotifier extends Notifier<LoadScenario> {
  @override
  LoadScenario build() => LoadScenario.success;
}

final NotifierProvider<SelectedScenarioNotifier, LoadScenario>
    selectedScenarioProvider =
    NotifierProvider<SelectedScenarioNotifier, LoadScenario>(
      SelectedScenarioNotifier.new,
    );

final FutureProvider<List<Shipment>> shipmentsProvider =
    FutureProvider<List<Shipment>>((Ref ref) async {
      final LoadScenario scenario = ref.watch(selectedScenarioProvider);
      final ShipmentRepository repository = ref.watch(
        shipmentRepositoryProvider,
      );

      return repository.fetchShipments(scenario);
    });

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'AsyncValue States Sample',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const ShipmentStatusPage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final LoadScenario selectedScenario = ref.watch(selectedScenarioProvider);
    final AsyncValue<List<Shipment>> shipmentsAsync = ref.watch(
      shipmentsProvider,
    );

    return Scaffold(
      appBar: AppBar(
        title: const Text('出荷一覧の3状態サンプル'),
        actions: <Widget>[
          IconButton(
            onPressed: () => ref.invalidate(shipmentsProvider),
            icon: const Icon(Icons.refresh),
            tooltip: '再読み込み',
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Text(
            '状態を切り替えると、loading -> data / empty / error の見え方を確認できます。',
            style: Theme.of(context).textTheme.bodyLarge,
          ),
          const SizedBox(height: 16),
          ScenarioSelector(
            selectedScenario: selectedScenario,
            onSelected: (LoadScenario scenario) {
              ref.read(selectedScenarioProvider.notifier).state = scenario;
            },
          ),
          const SizedBox(height: 24),
          AsyncValuePane<List<Shipment>>(
            value: shipmentsAsync,
            isEmpty: (List<Shipment> items) => items.isEmpty,
            loadingLabel: '出荷一覧を読み込んでいます...',
            emptyTitle: '出荷データがありません',
            emptyMessage: '0 件のときは失敗ではなく、条件に一致するデータがない状態として扱います。',
            onRetry: () => ref.invalidate(shipmentsProvider),
            dataBuilder: (BuildContext context, List<Shipment> items) {
              return Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    '取得件数: ${items.length}件',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 12),
                  for (final Shipment shipment in items)
                    ShipmentCard(shipment: shipment),
                ],
              );
            },
          ),
        ],
      ),
    );
  }
}

class ScenarioSelector extends StatelessWidget {
  const ScenarioSelector({
    super.key,
    required this.selectedScenario,
    required this.onSelected,
  });

  final LoadScenario selectedScenario;
  final ValueChanged<LoadScenario> onSelected;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(
          '取得結果を切り替える',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 12),
        SegmentedButton<LoadScenario>(
          segments: LoadScenario.values
              .map(
                (LoadScenario scenario) => ButtonSegment<LoadScenario>(
                  value: scenario,
                  label: Text(scenario.label),
                ),
              )
              .toList(growable: false),
          selected: <LoadScenario>{selectedScenario},
          onSelectionChanged: (Set<LoadScenario> values) {
            onSelected(values.first);
          },
        ),
      ],
    );
  }
}

class AsyncValuePane<T> extends StatelessWidget {
  const AsyncValuePane({
    super.key,
    required this.value,
    required this.isEmpty,
    required this.loadingLabel,
    required this.emptyTitle,
    required this.emptyMessage,
    required this.onRetry,
    required this.dataBuilder,
  });

  final AsyncValue<T> value;
  final bool Function(T data) isEmpty;
  final String loadingLabel;
  final String emptyTitle;
  final String emptyMessage;
  final VoidCallback onRetry;
  final Widget Function(BuildContext context, T data) dataBuilder;

  @override
  Widget build(BuildContext context) {
    return value.when(
      loading: () {
        return StateCard(
          icon: Icons.hourglass_bottom,
          title: '読み込み中',
          message: loadingLabel,
          showProgress: true,
        );
      },
      error: (Object error, StackTrace stackTrace) {
        return StateCard(
          icon: Icons.cloud_off,
          title: '取得に失敗しました',
          message: error.toString(),
          actionLabel: '再試行する',
          onAction: onRetry,
        );
      },
      data: (T data) {
        if (isEmpty(data)) {
          return StateCard(
            icon: Icons.inbox_outlined,
            title: emptyTitle,
            message: emptyMessage,
            actionLabel: 'もう一度読み込む',
            onAction: onRetry,
          );
        }

        return dataBuilder(context, data);
      },
    );
  }
}

class StateCard extends StatelessWidget {
  const StateCard({
    super.key,
    required this.icon,
    required this.title,
    required this.message,
    this.actionLabel,
    this.onAction,
    this.showProgress = false,
  });

  final IconData icon;
  final String title;
  final String message;
  final String? actionLabel;
  final VoidCallback? onAction;
  final bool showProgress;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: <Widget>[
            Icon(icon, size: 40),
            const SizedBox(height: 12),
            Text(
              title,
              style: Theme.of(context).textTheme.titleMedium,
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 8),
            Text(
              message,
              style: Theme.of(context).textTheme.bodyMedium,
              textAlign: TextAlign.center,
            ),
            if (showProgress) ...<Widget>[
              const SizedBox(height: 16),
              const CircularProgressIndicator(),
            ],
            if (actionLabel != null && onAction != null) ...<Widget>[
              const SizedBox(height: 16),
              FilledButton(
                onPressed: onAction,
                child: Text(actionLabel!),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

class ShipmentCard extends StatelessWidget {
  const ShipmentCard({
    super.key,
    required this.shipment,
  });

  final Shipment shipment;

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: ListTile(
        leading: CircleAvatar(child: Text('${shipment.id}')),
        title: Text(shipment.code),
        subtitle: Text('${shipment.customer} / ${shipment.status}'),
      ),
    );
  }
}

コードのポイント

① 表示する状態の切り替えは selectedScenarioProvider が持つ

final NotifierProvider<SelectedScenarioNotifier, LoadScenario>
    selectedScenarioProvider =
    NotifierProvider<SelectedScenarioNotifier, LoadScenario>(
      SelectedScenarioNotifier.new,
    );

画面上で success、empty、error を切り替える state を 1 か所へ寄せています。UI 側はこの provider を読むだけで、今どの条件で取得するかを判断できます。

② 非同期取得は FutureProvider で表現する

final FutureProvider<List<Shipment>> shipmentsProvider =
    FutureProvider<List<Shipment>>((Ref ref) async {
      final LoadScenario scenario = ref.watch(selectedScenarioProvider);
      final ShipmentRepository repository = ref.watch(
        shipmentRepositoryProvider,
      );

取得条件を watch しているので、シナリオが変わるたびに shipmentsProvider の結果も更新されます。どの状態で何を返すかは ShipmentRepository 側に残し、画面は取得結果だけを見る構成です。

③ 3 状態の見せ方は AsyncValuePane<T> にまとめる

AsyncValuePane<List<Shipment>>(
  value: shipmentsAsync,
  isEmpty: (List<Shipment> items) => items.isEmpty,
  loadingLabel: '出荷一覧を読み込んでいます...',
  emptyTitle: '出荷データがありません',
  onRetry: () => ref.invalidate(shipmentsProvider),
  dataBuilder: (BuildContext context, List<Shipment> items) {

画面ごとに when を書き散らす代わりに、共通 UI を AsyncValuePane<T> に閉じ込めています。差し替えるのは dataBuilder と文言だけなので、loading、empty、error の見た目をそろえやすくなります。

AsyncValue.when を画面ごとに直接書き始めると、空状態のメッセージや再試行ボタンの位置がすぐばらつきます。今回の AsyncValuePane<T> は、一覧ごとに違うのは dataBuilder だけにし、それ以外の見せ方をそろえる入口です。

5. flutter run で各状態を確認する

lib/main.dart を保存したら、次のコマンドで起動します。

flutter run

起動後は上部のセグメントを順に切り替えます。

正常データ表示:3件の出荷一覧が表示された状態

5-1. ローディング表示を確認する

どの状態へ切り替えても、最初に 2 秒間のローディング表示が出ます。ここで読み込み中の文言とスピナーがまとまっているかを確認します。

5-2. 空状態を確認する

0件 を選ぶと、取得失敗ではなく「条件に一致するデータがない」画面になります。検索画面やフィルター付き一覧では、この見せ方のほうが次の行動を案内しやすくなります。

ローディング表示:0件選択後に読み込み中のスピナーが出た状態 空状態:出荷データがありませんと表示された状態

5-3. エラー表示と再試行を確認する

失敗 を選ぶと、例外メッセージと再試行ボタンが出ます。再試行しても selectedScenarioProvider失敗 のままなら同じエラーになります。正常データ へ戻してから再試行すると、一覧表示へ復帰します。

エラー表示:取得に失敗しましたと再試行ボタンが表示された状態

6. 共通 UI と後続記事へのつなぎ方

今回のサンプルで持ち帰りたいのは、AsyncValue 自体よりも分岐の置き場です。

  • ローディング表示は「待っている最中」だと伝える
  • 空状態は「取得成功だが 0 件」であることを示す
  • エラー表示は「失敗したので再試行できる」と案内する

これを ConsumerWidget ごとに毎回書くより、AsyncValuePane<T> のような共通 Widget へ寄せたほうが、一覧画面、詳細画面、検索結果画面で文言やボタン位置をそろえやすくなります。

役割分担は次のように考えると整理しやすくなります。

  • REST API 記事: どこへ通信し、どこで失敗を拾うか
  • Riverpod 記事: 状態をどこへ置き、どこで更新するか
  • 今回の記事: 取得結果を UI でどう受け止めるか

実 API へつなぐときは、今回の ShipmentRepositoryhttpdio を使う実装へ差し替えるだけで、FutureProviderAsyncValuePane<T> の骨格はそのまま維持できます。

7. まとめ

非同期画面で揃えたいのは、スピナーを出すことではありません。ローディング、0 件、失敗を別の意味として扱い、利用者が次に何をすればよいか分かる見せ方にすることです。Riverpod を入れたあとにこの 3 状態を固めておくと、次に一覧検索や CRUD を足すときも画面ごとの差が増えにくくなります。

シリーズ 20/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える 現在の記事
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)