state-management-riverpod について
Flutter 開発者向けの Riverpod 状態管理スキルです。状態設計、依存性注入、ライフサイクルの指針をまとめ、イミュータブルな状態、Repository 経由の単一書き込み経路、Riverpod 3.x の利用方法を扱います。
主な機能
- 01イミュータブルな状態で機能を構成
- 02Repository 経由で永続化を書き込み
- 03Provider による依存性注入
- 04読み取り・副作用・ライフサイクルの指針
- 05Riverpod 3.x のテスト資料
- 06旧式 Provider パターンの検査
活用シーン
- 01Flutter 機能の状態と ViewModel の設計
- 02Repository のコミット後に UI を更新
- 03エンティティやセッション状態の自動破棄
- 04プルリクエスト前に旧式 Provider を検査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill state-management-riverpodインストール後、Claude Code で/state-management-riverpodを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/composition_root.darttask_list_feature.dartreferences/ownership-and-lifecycle.mdreads-and-side-effects.mdriverpod3-api-and-testing.mdscripts/ban-legacy-providers.sh詳細
状態管理(Riverpod)
状態と依存関係の注入には、Riverpod 3.x という 1 つの仕組みを使います。各機能には Notifier/AsyncNotifier/StreamNotifier の ViewModel を 1 つ置き、不変の状態値を公開します。シンプルな ConsumerWidget はその状態を読み取ります。ウィジェットが保持するのは一時的な UI 状態のみです。永続する変更はすべて、1 つのリポジトリ(唯一の書き込み経路)を通します。このスキルでは、まず状態管理ライブラリに依存しない基本事項を説明し、次に Riverpod の「使い方」、最後に公式 Flutter ガイドのスタックで使われる Provider/ChangeNotifier の補足を説明します。
作業内容に応じて、次のリファレンスを確認してください。
references/ownership-and-lifecycle.md— 所有権の表(どのプロバイダー形式を使うか)、family/autoDispose/onDispose のルール、コンポジションルートでの DI。references/reads-and-side-effects.md— watch と read と listen と select の使い分け、古いクロージャの落とし穴、voidのアクションメソッド、ref.mounted/BuildContextのガード。references/riverpod3-api-and-testing.md— Riverpod 3.x の API 変更(legacy の移行先、overrideWithValue、retry、ProviderContainer.test)、2023 年のチュートリアルで誤っている点、テストの境界。
PR の前に scripts/ban-legacy-providers.sh を実行してください。
絶対に守るルール
これらは状態管理ライブラリに関係なく適用されます。
- 機能ごとに ViewModel を 1 つ置き、不変の状態値を 1 つ持たせます。 状態は値の等価性を持つ値型(
freezed/sealed とcopyWith)にし、可変フィールドの寄せ集めにはしません。機能間で共有する状態は ViewModel ではなくリポジトリに置きます。同じ事実を 2 つの ViewModel が所有すると、食い違う 2 つの事実になってしまいます。 - 状態は非公開にし、意図を表すメソッドを通じてのみ変更します。 ウィジェットから状態のフィールドに直接触れてはいけません。ビューは
notifier.rename(id, name)を呼び、状態を直接設定することはありません。これが状態を変更する唯一の入口であり、すべての変更を 1 か所でレビューできます。 - 状態遷移のたびに 新しい 値を割り当てます。 値の等価性を使うリスナーは値で差分を検出します。インスタンスを直接変更して同じ参照を再割り当てすると変更が検出されず、UI が気づかれないまま古い状態になります。必ず
copyWithで新しいインスタンスを作成します。 - 保存するのではなく、導出します。 既存の状態から計算できる値(合計、フィルター後の件数、連続記録など)はゲッターまたはストリームの射影にします。別のフィールドとして保存してはいけません。導出値を保存すると、第 2 の真実の情報源となり、同期がずれていきます。
- 書き込み経路は 1 つにします。 永続する変更はすべて、トランザクションで永続化し、コミットが完了してからのみ戻るリポジトリメソッド 1 つを通します。UI が更新されるのはストリームが再通知されるためであり、楽観的に再公開するためではありません。公開前に永続化することで、クラッシュ時の安全性を規律に頼らず構造的に確保できます。
- 単方向データフローにします。 意図 → ViewModel → リポジトリ/サービス → 新しい状態 → ビュー、という流れにします。ビューは読み取ったデータを変更せず、経路を飛ばして上流に戻すこともありません。データは下流へ、イベントは上流へ流れます。
- 抽象に依存させ、注入します。決して自分で生成しません。 ViewModel にはリポジトリ/サービス/クロックを渡します。稼働中の依存オブジェクトを
newしたり、BuildContextを保持したりしてはいけません。これにより、フェイクを使ったテストやフレーバーごとの差し替えが可能になります。 - 非同期状態は、分散したフラグではなく明示的な状態としてモデル化します。 1 つの
AsyncValue(またはステータス enum + データ + エラー)を網羅的に描画します。状態の半端な設定や不可能な状態を表せてしまうisLoading/hasErrorのブール値をあちこちに散在させてはいけません。 - 削除時には参照を連鎖的にクリーンアップします。 エンティティを削除するときは、そのエンティティへのすべての参照(割り当て、外部キー、選択状態)も取り除きます。削除済みの親を指す項目は、後からクラッシュを引き起こす潜在的な原因です。孤立した項目を黙って削除してはいけません。明示的に別の場所へ移すか、割り当てを解除します。
- 状態ロジックで
DateTime.now()を使いません。 「現在時刻」はpackage:clockのClockを注入して渡し、clockProviderとして公開します。これにより、時間依存の動作をテストで決定的にできます(clockProvider.overrideWithValue(Clock.fixed(t)))。DateTime.now()も、独自のClockServiceも使いません。clockProviderの差し替え口はservice-boundary-and-nativeが担当します。
Riverpod:適切な形を選ぶ
所有するものに合わせてプロバイダーの形を選びます(表全体は references/ownership-and-lifecycle.md を参照してください)。
| 所有するもの | 形 |
|---|---|
| ライブクエリを基にした機能ごとのプレゼンテーション状態 | build() がリポジトリのストリームを返す StreamNotifier |
| ストリームではない、機能ごとのプレゼンテーション状態 | 1 つの不変値を扱う Notifier / AsyncNotifier |
| DB のリアクティブな投影 | リポジトリ/DAO のストリームを扱う StreamProvider(保存しない) |
注入するコラボレーター(リポジトリ、サービス、Clock) | 通常の Provider(DI) |
| エンティティ/セッションごとの状態 | Notifier/AsyncNotifier/StreamNotifier の .family + .autoDispose |
デフォルトでは手書きのプロバイダー(StreamNotifierProvider/AsyncNotifierProvider(...)、.autoDispose/.family 修飾子)を使用します。@riverpod によるコード生成は任意の利便機能であり、デフォルトではありません(リファレンスを参照)。Riverpod 3.0 の API のみを使用します。基底クラスは Notifier/AsyncNotifier/StreamNotifier です。autoDispose/family はプロバイダーの修飾子であり、基底クラスの AutoDispose*/*Family は使用しません(3.0 で削除されました)。
// features/task — one StreamNotifier ViewModel: build() returns the LIVE repo stream,
// so a committed write re-emits and the UI updates with no manual republish (D6).
class TaskListNotifier extends StreamNotifier<TaskListState> {
@override
Stream<TaskListState> build() {
final filter = ref.watch(taskFilterProvider); // in-session filter, its own Notifier
return ref.watch(taskRepositoryProvider).watchAll() // the live source of truth
.map((tasks) => TaskListState(tasks: tasks, filter: filter));
}
// Durable act routes through the single write path. void so the call site never drops
// the Future; no `state = ...` — the committed write makes watchAll() re-emit.
void complete(TaskId id) =>
unawaited(ref.read(taskRepositoryProvider).markComplete(id).catchError(_report));
void _report(Object error, StackTrace stack) {/* surface via a logger — never swallow */}
}
final taskListNotifierProvider =
StreamNotifierProvider.autoDispose<TaskListNotifier, TaskListState>(
TaskListNotifier.new);
// The reversible in-session filter is its own tiny Notifier (manual NotifierProvider).
class TaskFilterNotifier extends Notifier<TaskFilter> {
@override
TaskFilter build() => TaskFilter.all;
void set(TaskFilter f) => state = f; // watched by build() above; re-projects the stream
}
final taskFilterProvider =
NotifierProvider<TaskFilterNotifier, TaskFilter>(TaskFilterNotifier.new);
Riverpod:プロバイダーは DI(例外をスローする継ぎ目)
ViewModel はプロバイダーからコラボレーターを読み取り、コンポジションルートで実際の実装を一度だけ配線します。プレースホルダーの継ぎ目はオーバーライドされるまで例外をスローするため、依存関係の配線漏れは、テスト内で実サービスをひそかに構築する代わりに、最初の読み取り時に明確に失敗します。
// A seam: throws until a flavor main() overrides it. One live impl per flavor.
final databaseProvider = Provider<AppDatabase>(
(ref) => throw UnimplementedError('override databaseProvider in main()'),
);
final taskRepositoryProvider = Provider<TaskRepository>(
(ref) => TaskRepository(ref.watch(databaseProvider)), // inject, never `new` a live DB here
);
// main.dart — the ONLY place live collaborators are constructed.
void main() {
final db = AppDatabase.open();
runApp(ProviderScope(
overrides: [databaseProvider.overrideWithValue(db)],
child: const App(),
));
}
プロバイダーを追加する前に、コンストラクター引数で済ませられないか考えてください。プロバイダー数の増加は進歩ではなく、設計上の懸念事項です。通常のプロバイダーでは必要性を表現できない場合に限り、family/スコープ設定/コード生成を使用します。
Riverpod:読み取り — watch / read / listen
詳しい理由は references/reads-and-side-effects.md を参照してください。
ref.watch(p)/ref.watch(p.select((s) => s.field))—build()内で表示に使用します。.selectを使うと、1 つのフィールドの変更に再ビルドを絞り込めます。大きなウィジェットの先頭でコントローラー全体をwatchしないでください。ref.read(p.notifier)— コールバック(onTap、onPressed)内で使用します。build()内で値をreadすると古いデータのまま固定され、コールバック内でwatchすると予期せず再ビルドされます。ref.listen(p, ...)—build()内で、変更時の副作用(遷移、スナックバー表示)に使用します。build()から副作用を直接実行しないでください。
class TaskCounter extends ConsumerWidget {
const TaskCounter({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final n = ref.watch(taskListNotifierProvider.select((s) => s.value?.openCount ?? 0));
return Text('$n');
}
}
古いクロージャーの落とし穴: ref.watch した値を onTap クロージャーにキャプチャしないでください。安定したキーを渡し、タップ時に ref.read で解決します。素早く再タップすると、キャプチャした値がひそかに前のエンティティを対象にしますが、これを検出するリンターはありません。
// WRONG — acts on a stale item after a re-render.
onTap: () => ref.read(p.notifier).complete(task),
// RIGHT — id is stable; resolve now.
onTap: () => ref.read(p.notifier).complete(task.id),
アクション経路のメソッドは void を返します。 onTap: () => notifier.doThing() のようなアロー関数は「返す」Future があっても、ターゲット型が VoidCallback のため、discarded_futures も unawaited_futures も満たしません。その結果、Future とそのエラーが破棄されます。意図を示すメソッドを void にすれば、この落とし穴を回避できます。非同期処理は、その内部で unawaited(_run().catchError(_report)) を使って開始します。async-safety を参照してください。
Riverpod:ライフサイクルと破棄
- エンティティ/セッションごとの状態は、安定した等価性を持つ値(ID または
@freezedの引数レコード)をキーとする.familyを使い、ミュータブルなオブジェクトは使いません。また、重いセッション状態がアンマウント時に破棄されるよう.autoDisposeを指定します。 - アプリスコープのシングルトン(データベース、エンジン、サービス)は通常の
Providerを使い、autoDisposeは指定しません。 awaitの後の書き込みはref.mountedで保護します(use_build_context_synchronouslyに相当する Riverpod の仕組みです)。- 所有するリソース(ストリーム、コントローラー、ネイティブハンドル)は
ref.onDisposeで解放します。Riverpod はStreamProviderのサブスクリプションを管理するため、手動で管理する必要はありません。
Riverpod:アクセシビリティの状態はプロバイダーを経由しません
MediaQuery.boldTextOf(context) / .textScalerOf(context) / .highContrastOf(context) は、プロバイダー経由ではなく、ウィジェットのビルド時に読み取ってください。MediaQuery は適切に無効化されるよう設計された InheritedWidget です。これをプロバイダー経由にすると、プロバイダー本体で BuildContext を使うか、1 フレーム古い値を手動で同期することになります。アプリ/ドメインの状態には Riverpod を、プラットフォーム/アクセシビリティの状態には BuildContext を使用してください。accessibility-as-code を参照してください。
アンチパターン
- レガシープロバイダー —
StateProvider/StateNotifierProvider/ChangeNotifierProvider(flutter_riverpod/legacy.dartに移動済み)、または Riverpod と併用するget_it/package:provider/ Bloc の追加。DI/状態管理の仕組みを二つ使うのは、一つに絞るより明らかに劣ります。 - プロバイダー本体にビジネスロジックを置くこと。 プロバイダーは薄い接続層です。ロジックは ViewModel、エンジン、リポジトリに置いてください。
- 状態の値をその場で変更して、同じインスタンスを再代入すること。 値の等価性を使うリスナーには変更が認識されません。
- 大きなウィジェットの先頭で
ref.watchすること。 フィールドが変わるたびにサブツリー全体が再構築されます。.selectまたは末端のConsumerWidgetを使ってください。 - アプリ/ドメインの状態に
setState()を使うこと。setState/ローカルのStateは、一時的な UI 状態(トグルやアニメーション実行中フラグなど)にのみ使います。 - 確定前に楽観的に再公開すること。 再公開と確定の間にクラッシュすると、ディスクには一度も保存されていない事実が表示されます。
- 依存オブジェクトを
newする、BuildContextを保持する、画面遷移する、またはスナックバーを表示する ViewModel。 依存オブジェクトは注入し、状態を公開し、ビュー(またはルーターのリダイレクト)に反応させてください。 - 変更可能なオブジェクトを
familyのキーに使うこと、アプリスコープのシングルトンにautoDisposeを設定すること、または本来追加すべきでないautoDisposeを取り消すためにkeepAlive()を使うこと。
完了の定義
- 機能の状態は、不変値を扱う単一の
Notifier/AsyncNotifier/StreamNotifierで管理し、ウィジェットが保持するのは一時的な UI 状態のみです。 - 状態は非公開にし、すべての変更は新しい値を代入するインテントメソッドを経由します。その場での変更はありません。
- 派生値はゲッター/ストリームの射影で表現し、フィールドとして保存しません。
- 非同期状態は単一の
AsyncValue/ステータスとして網羅的に描画し、独立したローディング/エラーのブール値は使いません。 - 永続化を伴う変更はすべて、確定してから戻るリポジトリメソッドで行います。確定前の楽観的な再公開はありません。
- 表示には
ref.watch/.select、コールバックにはref.read(p.notifier)、副作用にはref.listenを使います。ウィジェット先頭で広範囲にwatchしたり、取得時の値をクロージャーに取り込んだりしません。 - 依存オブジェクトはプロバイダー経由で注入し、置き換えられるまでは接続箇所が例外を送出します。実運用の実装は
mainでフレーバーごとに一度だけ接続し、ViewModel ではBuildContext/DateTime.now()を使いません。 - エンティティごとのプロバイダーは、安定した値をキーとする
familyを使ってautoDisposeを設定します。アプリのシングルトンには通常のプロバイダーを使い、リソースはref.onDisposeで解放します。 -
legacy.dart、get_it、package:provider、Bloc のインポートはありません(scripts/ban-legacy-providers.shが成功します)。
関連するスキル
flutter-architecture— 機能優先の DAG において、ViewModel、リポジトリ、書き込み経路がどこに位置するかを説明します。error-handling-typed-results— リポジトリのメソッドが例外を投げる代わりに返す、Result/Failure の中核部分を扱います。async-safety— Future を破棄することで生じる問題、mountedガード、サブスクリプションやタイマーの破棄を扱います。persistence-drift—StreamProviderが投影する Drift ストリームと、変更ごとに 1 つのトランザクションを使う書き込み経路を扱います。service-boundary-and-native— あらゆる副作用とネイティブチャネルに対する、例外を投げる境界のプロバイダーパターンを扱います。これらの ViewModel がDateTime.now()の代わりに参照するclockProvider(package:clock)の境界も担います。widget-composition— この状態を読み取る、状態を持たないConsumerWidgetの View を扱います。accessibility-as-code— このスキルが共同で担う分担を扱います。アプリ/ドメインの状態には Riverpod を使い、プラットフォーム/アクセシビリティの状態はMediaQuery/BuildContextから読み取り、プロバイダーは使いません。value-objects-money-and-units— 決定論的な時間処理のためにClockパラメーターを受け取る値型を扱います。app-startup-and-bootstrap—ProviderScopeのオーバーライドを配線するコンポジションルートを扱います。
参考資料
Provider/ChangeNotifier 付録
Flutter 公式ガイドのスタック(package:provider と ChangeNotifier)では、仕組みだけが異なり、同じ 10 個のルールが適用されます。
- ViewModel =
ChangeNotifier、新しい状態 = すべてのフィールドを設定した後にnotifyListeners()を呼び出します(論理的な変更ごとに必ず 1 回だけ呼び出し、更新の途中やループ内では呼び出しません)。 - 状態は非公開にし、不変な形で公開します。 フィールドは
_privateにし、読み取り専用のゲッターを公開します。List.unmodifiable(...)を返し、変更可能なコレクションを渡さないでください。 - DI = 抽象型をコンストラクターインジェクションで注入し、画面より上位で
Provider/ProxyProviderを使って配線します(画面を閉じると自動破棄されます)。コンテナーとしてget_itを使わないでください。 - 読み取り = 表示には
context.select/Consumer/Selector、コールバックにはcontext.readを使います。didChangeDependenciesまたはコールバック内で読み取り、initStateでは読み取らないでください。 - ガード =
awaitの後にnotifyListeners()を呼び出す前に_disposedフラグを確認します。サブスクリプションをキャンセルし、所有するコントローラーはdispose()で破棄します。
enum ViewStatus { idle, loading, ready, error }
class TaskListViewModel extends ChangeNotifier {
TaskListViewModel(this._repo); // injected abstraction
final TaskRepository _repo;
ViewStatus _status = ViewStatus.idle;
ViewStatus get status => _status;
List<Task> _tasks = const [];
List<Task> get tasks => List.unmodifiable(_tasks); // immutable view
int get openCount => _tasks.where((t) => !t.done).length; // derive, don't store
Future<void> load() async {
_status = ViewStatus.loading; notifyListeners();
try {
_tasks = await _repo.load();
_status = ViewStatus.ready;
} catch (_) {
_status = ViewStatus.error;
}
notifyListeners(); // exactly one, after all fields set
}
Future<void> complete(TaskId id) async {
await _repo.markComplete(id); // single write path
_tasks = [for (final t in _tasks) t.id == id ? t.copyWith(done: true) : t];
notifyListeners();
}
}