公開日 2026-07-15

Flutterの状態管理入門(Riverpod最小構成)

Flutterで flutter_riverpod を最小導入し、NotifierProvider と派生 Provider で UI とロジックを分けながら状態変更と画面反映を確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. なぜ状態管理を分けるのか
  6. 3. プロジェクトを作成し、Riverpod を追加する
  7. 3-1. 環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. flutter_riverpod を追加する
  11. 4. 状態更新の流れを先に確認する
  12. 5. lib/main.dart を作成する
  13. コードのポイント
  14. 6. UI とロジックがどこで分かれるかを整理する
  15. ShipmentController の責務
  16. 派生 Provider の責務
  17. UI の責務
  18. 7. flutter run で動作を確認する
  19. 8. まとめ

Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う の次に整理しておきたいのが、画面状態の置き場です。setState だけでも小さな画面は動きますが、絞り込み条件、一覧件数、更新処理が増えるほど UI とロジックが同じ場所へ集まりやすくなります。この記事では flutter_riverpod を導入し、出荷一覧の絞り込みと完了反映を題材に、状態の保持、更新、画面反映を最小構成で確認します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築、Dart 基礎、ルーティング、REST API 通信、JSON モデル生成の入口までは終わっている人
  • setState で画面は動かせるが、画面が増えたときのロジックの置き場がまだ固まっていない人
  • 今後のローディング表示、フォーム、CRUD、認証記事へ進む前に、Riverpod の最小構成を押さえたい人

この記事で到達する状態

  • flutter_riverpod を導入し、ProviderScope でアプリ全体を包める
  • NotifierProvider で状態と更新メソッドをまとめられる
  • 派生 Provider で一覧の絞り込み結果と件数集計を作れる
  • フィルター変更と「完了にする」操作で、画面がどう再描画されるか説明できる

非対象

  • AsyncValue を使ったローディング・空状態・エラー表示
  • API 通信や DB 保存
  • Riverpod Generator や freezed
  • テストや複数ファイル分割

今回は同期状態の最小構成に絞ります。非同期取得やエラー表示まで一度に入れると焦点がぼけます。まずは「どこに状態を置き、どこで更新するか」を固めるところから始めます。

2. なぜ状態管理を分けるのか

setState 自体が悪いわけではありません。問題になりやすいのは、次の 3 つが 1 か所へ集まり始めるときです。

  • 画面が何を表示するか
  • ボタンやフィルターで状態をどう変えるか
  • 一覧件数や絞り込み結果をどう計算するか

たとえば出荷一覧画面なら、ステータスで絞り込みたい、未完了だけ表示したい、完了ボタンを押したら件数も変えたい、という要求がすぐ増えます。これを StatefulWidgetbuild() 周辺へ寄せ続けると、見た目のコードと更新ロジックと集計処理が同居しやすくなります。

Riverpod を入れる目的は、状態管理ライブラリを増やすことではなく、責務を整理することにあります。

  • 状態そのものは NotifierProvider
  • 状態から計算される一覧や件数は派生 Provider
  • UI は「監視する」「操作を通知する」だけ

この分担にすると、後から API 読み込みやフォーム検証を足すときも置き場が崩れにくくなります。

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

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

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

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

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

flutter create my_riverpod_app
cd my_riverpod_app

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

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

flutter emulators

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

flutter emulators --launch <emulator_id>

3-4. flutter_riverpod を追加する

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

flutter pub add flutter_riverpod

今回は Flutter アプリ側の Widget と一緒に使うため、riverpod ではなく flutter_riverpod を追加します。

4. 状態更新の流れを先に確認する

今回の流れは次の通りです。

flowchart LR
  A[UI] -->|ref.read| B[ShipmentController]
  B --> C[ShipmentState]
  C --> D[shipmentControllerProvider]
  D --> E[visibleShipmentsProvider]
  D --> F[shipmentSummaryProvider]
  E -->|ref.watch| A
  F -->|ref.watch| A

見る場所は次の 3 つ。

  • ShipmentController: 状態をどう変えるかを持つ
  • ShipmentState: 何を保持しているかを持つ
  • 派生 Provider: 一覧の絞り込み結果や件数集計を持つ

UI は Provider を監視し、操作が起きたら Notifier のメソッドを呼ぶだけです。件数計算や絞り込み条件の分岐を UI 側へ書き込まないので、build() の責務が軽くなります。

5. lib/main.dart を作成する

この lib/main.dart は、状態更新を Notifier に寄せ、派生一覧と集計を Provider で分ける最小構成です。UI がどこまでを監視し、どこから先をコントローラーへ任せているかを見ます。

lib/main.dart を次の内容で作成します。

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod State Sample',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const ShipmentDashboardPage(),
    );
  }
}

enum ShipmentStatus {
  waiting('未着手'),
  picking('ピッキング中'),
  packed('梱包済み');

  const ShipmentStatus(this.label);

  final String label;
}

class ShipmentItem {
  const ShipmentItem({
    required this.id,
    required this.code,
    required this.customerName,
    required this.status,
    required this.updatedAt,
  });

  final String id;
  final String code;
  final String customerName;
  final ShipmentStatus status;
  final DateTime updatedAt;

  ShipmentItem copyWith({
    ShipmentStatus? status,
    DateTime? updatedAt,
  }) {
    return ShipmentItem(
      id: id,
      code: code,
      customerName: customerName,
      status: status ?? this.status,
      updatedAt: updatedAt ?? this.updatedAt,
    );
  }
}

class ShipmentState {
  const ShipmentState({
    required this.items,
    this.selectedStatus,
    this.pendingOnly = false,
  });

  final List<ShipmentItem> items;
  final ShipmentStatus? selectedStatus;
  final bool pendingOnly;

  ShipmentState copyWith({
    List<ShipmentItem>? items,
    ShipmentStatus? selectedStatus,
    bool clearSelectedStatus = false,
    bool? pendingOnly,
  }) {
    return ShipmentState(
      items: items ?? this.items,
      selectedStatus:
          clearSelectedStatus ? null : selectedStatus ?? this.selectedStatus,
      pendingOnly: pendingOnly ?? this.pendingOnly,
    );
  }
}

class ShipmentSummary {
  const ShipmentSummary({
    required this.totalCount,
    required this.visibleCount,
    required this.pendingCount,
    required this.packedCount,
  });

  final int totalCount;
  final int visibleCount;
  final int pendingCount;
  final int packedCount;
}

final NotifierProvider<ShipmentController, ShipmentState>
    shipmentControllerProvider =
    NotifierProvider<ShipmentController, ShipmentState>(ShipmentController.new);

final Provider<List<ShipmentItem>> visibleShipmentsProvider =
    Provider<List<ShipmentItem>>((Ref ref) {
      final ShipmentState state = ref.watch(shipmentControllerProvider);

      return state.items.where((ShipmentItem item) {
        final bool matchesStatus =
            state.selectedStatus == null || item.status == state.selectedStatus;
        final bool matchesPendingOnly =
            !state.pendingOnly || item.status != ShipmentStatus.packed;
        return matchesStatus && matchesPendingOnly;
      }).toList(growable: false);
    });

final Provider<ShipmentSummary> shipmentSummaryProvider =
    Provider<ShipmentSummary>((Ref ref) {
      final ShipmentState state = ref.watch(shipmentControllerProvider);
      final List<ShipmentItem> visibleItems = ref.watch(visibleShipmentsProvider);
      final int packedCount = state.items
          .where((ShipmentItem item) => item.status == ShipmentStatus.packed)
          .length;
      final int waitingCount = state.items.length - packedCount;

      return ShipmentSummary(
        totalCount: state.items.length,
        visibleCount: visibleItems.length,
        pendingCount: waitingCount,
        packedCount: packedCount,
      );
    });

class ShipmentController extends Notifier<ShipmentState> {
  @override
  ShipmentState build() {
    return ShipmentState(items: _initialShipments);
  }

  void selectStatus(ShipmentStatus? status) {
    state = state.copyWith(
      selectedStatus: status,
      clearSelectedStatus: status == null,
    );
  }

  void togglePendingOnly(bool value) {
    state = state.copyWith(pendingOnly: value);
  }

  void markPacked(String shipmentId) {
    state = state.copyWith(
      items: <ShipmentItem>[
        for (final ShipmentItem item in state.items)
          if (item.id == shipmentId)
            item.copyWith(
              status: ShipmentStatus.packed,
              updatedAt: DateTime.now(),
            )
          else
            item,
      ],
    );
  }

  void resetFilters() {
    state = state.copyWith(
      clearSelectedStatus: true,
      pendingOnly: false,
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ShipmentState state = ref.watch(shipmentControllerProvider);
    final List<ShipmentItem> visibleItems = ref.watch(visibleShipmentsProvider);
    final ShipmentSummary summary = ref.watch(shipmentSummaryProvider);
    final ShipmentController controller =
        ref.watch(shipmentControllerProvider.notifier);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Riverpod で出荷一覧を管理する'),
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    '状態は Notifier に寄せ、UI は監視と操作通知だけにします。',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 8),
                  const Text(
                    '今回は API を呼ばず、一覧の絞り込みと完了反映に絞って Riverpod の最小構成を確認します。',
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          _SummaryCard(summary: summary),
          const SizedBox(height: 16),
          Text(
            'ステータスで絞り込む',
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: <Widget>[
              ChoiceChip(
                label: const Text('すべて'),
                selected: state.selectedStatus == null,
                onSelected: (_) => controller.selectStatus(null),
              ),
              for (final ShipmentStatus status in ShipmentStatus.values)
                ChoiceChip(
                  label: Text(status.label),
                  selected: state.selectedStatus == status,
                  onSelected: (bool selected) =>
                      controller.selectStatus(selected ? status : null),
                ),
            ],
          ),
          const SizedBox(height: 8),
          SwitchListTile(
            contentPadding: EdgeInsets.zero,
            title: const Text('未完了だけ表示'),
            value: state.pendingOnly,
            onChanged: controller.togglePendingOnly,
          ),
          Align(
            alignment: Alignment.centerRight,
            child: TextButton(
              onPressed: controller.resetFilters,
              child: const Text('フィルターをリセット'),
            ),
          ),
          const SizedBox(height: 8),
          Text(
            '対象一覧',
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 8),
          if (visibleItems.isEmpty)
            const Card(
              child: Padding(
                padding: EdgeInsets.all(16),
                child: Text('条件に合う出荷はありません。'),
              ),
            )
          else
            ...visibleItems.map(
              (ShipmentItem item) => Padding(
                padding: const EdgeInsets.only(bottom: 12),
                child: _ShipmentCard(
                  item: item,
                  onMarkPacked: item.status == ShipmentStatus.packed
                      ? null
                      : () => controller.markPacked(item.id),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class _SummaryCard extends StatelessWidget {
  const _SummaryCard({required this.summary});

  final ShipmentSummary summary;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            _SummaryItem(label: '総件数', value: '${summary.totalCount}'),
            _SummaryItem(label: '表示中', value: '${summary.visibleCount}'),
            _SummaryItem(label: '未完了', value: '${summary.pendingCount}'),
            _SummaryItem(label: '梱包済み', value: '${summary.packedCount}'),
          ],
        ),
      ),
    );
  }
}

class _SummaryItem extends StatelessWidget {
  const _SummaryItem({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Text(label, style: Theme.of(context).textTheme.labelMedium),
        const SizedBox(height: 4),
        Text(value, style: Theme.of(context).textTheme.titleLarge),
      ],
    );
  }
}

class _ShipmentCard extends StatelessWidget {
  const _ShipmentCard({
    required this.item,
    required this.onMarkPacked,
  });

  final ShipmentItem item;
  final VoidCallback? onMarkPacked;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                Text(item.code, style: Theme.of(context).textTheme.titleMedium),
                Chip(label: Text(item.status.label)),
              ],
            ),
            const SizedBox(height: 8),
            Text('取引先: ${item.customerName}'),
            const SizedBox(height: 4),
            Text('更新時刻: ${formatDateTime(item.updatedAt)}'),
            const SizedBox(height: 12),
            Align(
              alignment: Alignment.centerRight,
              child: FilledButton(
                onPressed: onMarkPacked,
                child: const Text('梱包済みにする'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

String formatDateTime(DateTime value) {
  final String month = value.month.toString().padLeft(2, '0');
  final String day = value.day.toString().padLeft(2, '0');
  final String hour = value.hour.toString().padLeft(2, '0');
  final String minute = value.minute.toString().padLeft(2, '0');
  return '$month/$day $hour:$minute';
}

final List<ShipmentItem> _initialShipments = <ShipmentItem>[
  ShipmentItem(
    id: 'shipment-001',
    code: 'S-1001',
    customerName: '東京商事',
    status: ShipmentStatus.waiting,
    updatedAt: DateTime(2026, 6, 25, 9, 0),
  ),
  ShipmentItem(
    id: 'shipment-002',
    code: 'S-1002',
    customerName: '大阪物産',
    status: ShipmentStatus.picking,
    updatedAt: DateTime(2026, 6, 25, 9, 30),
  ),
  ShipmentItem(
    id: 'shipment-003',
    code: 'S-1003',
    customerName: '名古屋販売',
    status: ShipmentStatus.packed,
    updatedAt: DateTime(2026, 6, 25, 10, 0),
  ),
  ShipmentItem(
    id: 'shipment-004',
    code: 'S-1004',
    customerName: '福岡流通',
    status: ShipmentStatus.waiting,
    updatedAt: DateTime(2026, 6, 25, 10, 20),
  ),
];

コードのポイント

ProviderScope が Riverpod の入口になる

runApp(const ProviderScope(child: MyApp()));

この1行があることで、アプリ全体で Provider を読めるようになります。まずは「Riverpod を使うアプリの外枠はここから始まる」と押さえるのが大切です。

② 状態変更は ShipmentController に集約する

final NotifierProvider<ShipmentController, ShipmentState>
    shipmentControllerProvider =
    NotifierProvider<ShipmentController, ShipmentState>(ShipmentController.new);

一覧の更新やフィルター変更を UI で直接書き換えず、ShipmentController に寄せています。状態を変える入口が1か所にまとまるので、画面数が増えても変更点を追いやすくなります。

③ 派生一覧と集計は別 Provider へ切り出す

final Provider<List<ShipmentItem>> visibleShipmentsProvider =
    Provider<List<ShipmentItem>>((Ref ref) {
      final ShipmentState state = ref.watch(shipmentControllerProvider);

      return state.items.where((ShipmentItem item) {
        final bool matchesStatus =
            state.selectedStatus == null || item.status == state.selectedStatus;
        final bool matchesPendingOnly =
            !state.pendingOnly || item.status != ShipmentStatus.packed;
        return matchesStatus && matchesPendingOnly;
      }).toList(growable: false);
    });

final Provider<ShipmentSummary> shipmentSummaryProvider =
    Provider<ShipmentSummary>((Ref ref) {
      final ShipmentState state = ref.watch(shipmentControllerProvider);
      final List<ShipmentItem> visibleItems = ref.watch(visibleShipmentsProvider);
      final int packedCount = state.items
          .where((ShipmentItem item) => item.status == ShipmentStatus.packed)
          .length;

      return ShipmentSummary(
        totalCount: state.items.length,
        visibleCount: visibleItems.length,
        pendingCount: state.items.length - packedCount,
        packedCount: packedCount,
      );
    });

表示対象の一覧と件数集計を別の Provider にしているため、UI 側は結果だけを watch すれば済みます。絞り込み条件が増えても、コントローラーと派生計算の責務を分けたまま拡張できます。

ProviderScope が最初の入口です。これがないと Provider をアプリ全体で読めません。

6. UI とロジックがどこで分かれるかを整理する

このサンプルで責務を分けている場所は次の通りです。

ShipmentController の責務

  • selectStatus() で絞り込み条件を更新する
  • togglePendingOnly() で未完了フィルターを更新する
  • markPacked() で対象行の状態を更新する
  • resetFilters() で条件を初期化する

状態の変更方法はすべて Notifier に集めています。UI 側での List 直接書き換えや件数再計算は行いません。

派生 Provider の責務

  • visibleShipmentsProvider は表示対象の一覧を作る
  • shipmentSummaryProvider は総件数、表示中件数、未完了件数、梱包済み件数を作る

ここを分けると、一覧の絞り込み条件が変わっても、UI は結果だけ受け取れば済みます。後から API の戻り値やフォーム条件が増えても、集計ロジックの置き場を変えずに済みます。

UI の責務

  • ref.watch() で状態と派生結果を監視する
  • ref.watch(...notifier) でコントローラーを取り出し、ユーザー操作をメソッド呼び出しで通知する
  • 表示に専念する

画面側の build() には「どのボタンがどのメソッドを呼ぶか」だけが残ります。これが Riverpod 導入の一番大きい効果です。

7. flutter run で動作を確認する

コードを書いたら、次のコマンドで起動します。

flutter run

起動したら次の 3 点を確認します。

  1. ピッキング中未着手ChoiceChip を押すと、一覧件数と表示対象が切り替わる
  2. 未完了だけ表示 をオンにすると、梱包済み の行が非表示になる
  3. 梱包済みにする を押すと、その行のステータスと件数カードが同時に更新される

3 つ目が確認できれば、状態変更が Provider 経由で UI へ戻っていることが分かります。setState を各カードへ散らさなくても、状態の変化を 1 か所から反映できます。

出荷一覧の初期表示。件数カード、ステータスフィルター、一覧カードが並ぶ ピッキング中に絞り込み、未完了のみ表示をオンにした状態 梱包済みにするを押した直後。件数カードの未完了と梱包済みの数が更新されている

8. まとめ

Riverpod の最初の一歩は、難しい機能を増やすことではありません。状態を持つ場所、計算する場所、表示する場所を分けるところが出発点です。今回のように同期状態の絞り込み画面から始めておくと、次にローディング表示やエラー表示、フォーム、CRUD を足すときも構成を保ちやすくなります。

Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う でモデルの置き場を整えたら、次はそのモデルを Riverpod で画面へ流す段階です。まずは手元の一覧画面を 1 つ選び、setState で持っているフィルター条件と一覧件数を Provider へ移してみてください。

シリーズ 19/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最小構成)