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 か所へ集まり始めるときです。
- 画面が何を表示するか
- ボタンやフィルターで状態をどう変えるか
- 一覧件数や絞り込み結果をどう計算するか
たとえば出荷一覧画面なら、ステータスで絞り込みたい、未完了だけ表示したい、完了ボタンを押したら件数も変えたい、という要求がすぐ増えます。これを StatefulWidget の build() 周辺へ寄せ続けると、見た目のコードと更新ロジックと集計処理が同居しやすくなります。
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 点を確認します。
ピッキング中や未着手のChoiceChipを押すと、一覧件数と表示対象が切り替わる未完了だけ表示をオンにすると、梱包済みの行が非表示になる梱包済みにするを押すと、その行のステータスと件数カードが同時に更新される
3 つ目が確認できれば、状態変更が Provider 経由で UI へ戻っていることが分かります。setState を各カードへ散らさなくても、状態の変化を 1 か所から反映できます。
8. まとめ
Riverpod の最初の一歩は、難しい機能を増やすことではありません。状態を持つ場所、計算する場所、表示する場所を分けるところが出発点です。今回のように同期状態の絞り込み画面から始めておくと、次にローディング表示やエラー表示、フォーム、CRUD を足すときも構成を保ちやすくなります。
Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う でモデルの置き場を整えたら、次はそのモデルを Riverpod で画面へ流す段階です。まずは手元の一覧画面を 1 つ選び、setState で持っているフィルター条件と一覧件数を Provider へ移してみてください。