Riverpodとステートマシンを仲良くさせる完全ガイド
はじめに
Flutter で規模が大きくなってくると 「状態をどう整理するか」 が永遠のテーマになります。
この記事では、Riverpod と 有限ステートマシン (FSM) の組み合わせで "予測可能でテストしやすい" UI を作るコツをまとめました。
難しい数学の話はなしで "Riverpod 使ってるけど、ごちゃっとしてきた…"という開発者向けのライトなガイドです。
TL;DR
- Notifier / AsyncNotifier で FSM を実装するとロジックがスッキリ
freezed
と併用して「状態=値オブジェクト」にするのがポイント==
の比較とselect()
で無駄なリビルドを防ごう
1. Riverpod おさらい
キーワード | 役割 | ひとこと |
---|---|---|
Provider | 値を公開する最小単位 | 「グローバル変数だけど安全」 |
Notifier / AsyncNotifier | 変更可能な状態 + ロジックを包むクラス | (UI から見れば)ViewModel 的な立ち位置 |
ref | 依存を読んだり破棄したりするハンドル | DI もライフサイクルもこれ一本 |
AsyncValue |
loading / data / error を一つの型で扱う |
非同期 UI の味方 |
ポイントは Riverpod の再描画判定は ==
だということ。identical
や updateShouldNotify
は登場しません。
2. ステートマシンって何?
「状態を全部列挙して、イベントで遷移を定義する」だけ──仕組みはシンプルですが、UI でありがちな「ありえない状態」を撲滅する強力なパターンです。
2‑1. 用語ざっくり
用語 | 意味 | UI での例 |
---|---|---|
State | システムが取りうる一つひとつの "居場所" |
Loading / Success / Error
|
Event | 状態を変える入力 (クリックや API の応答) | 「再読み込みボタンを押した」 |
Transition | State A ➡️ State B への経路 |
Loading → Success
|
Action / Side‑Effect | 遷移時に走る処理 | API を叩く / ログを送る |
これだけ覚えれば 8 割は OK 😀
2‑2. 3 ステップで考えるフロー
- 状態を列挙する: まずは付箋に全部書き出す
- イベントを洗い出す: ボタン、タイマー、外部通知…
- 線でつなぐ: 「このイベントが来たらどこへ行く?」
例として ロード付きリスト画面 を図にすると:
ポイント
- 状態は 排他的。
Loading
とLoaded
が同時に存在することはない- 新しい機能は "状態 or イベントを足す" だけで考えられる
2‑3. Flutter で活かすコツ
-
Widget の if/else が減る …
state.when(...)
に集約 - テストがラク … 与えたイベントと期待状態を比べるだけ
- ドキュメントになる … 図を README に貼ると新メンバーが喜ぶ
2‑4. UI 以外でも活躍する FSM
本記事では UI 目線で解説していますが、ステートマシンはバックグラウンドやサーバーサイドでも威力を発揮します。
-
ジョブワーカーのフロー管理
- キュー投入 → 実行中 → 成功 / リトライ / 恒久エラー … といった状態遷移をコードで明示できます。
-
ネットワーク接続や Bluetooth ハンドシェイク
-
Disconnected → Connecting → Connected → Error
など、通信ライフサイクルを表現しやすい。
-
-
バックエンドのビジネスワークフロー
- 例:注文
Placed → Paid → Shipped → Delivered
のように、人・システムを跨ぐプロセスでも FSM は読みやすいドキュメントになります。
- 例:注文
要するに "状態が有限で、イベントで動くもの" なら何でもステートマシン化できる、というわけです。
3. Riverpod で FSM を組むパターン
💡 sealed class のススメ
Dart 3 で導入されたsealed class
は、freezed
を用いた Union 型(特に FSM の状態定義)と非常に相性が良いです。freezed
でsealed
を用いることで以下のメリットが得られます。
- 網羅性チェック —
switch
/when
で未処理の状態がコンパイル時にエラー(abstract class
のみでは保証されない)- 意図しない継承・実装を防ぐ — FSM の「状態リスト」をライブラリ外部から勝手に増やされない
- ドキュメント性 —
sealed
と書いてあるだけで「ここで状態が完結する」と伝わる
3‑1. シンプルな同期 FSM — テーマ切り替え
enum AppTheme { light, dark }
class ThemeNotifier extends _$ThemeNotifier {
AppTheme build() => AppTheme.light;
void toggle() => state =
state == AppTheme.light ? AppTheme.dark : AppTheme.light;
}
使い方は ref.watch(themeNotifierProvider)
を UI で読むだけ。
3‑2. 非同期 FSM — プロフィール取得
class ProfileNotifier extends _$ProfileNotifier {
Future<User> build() async {
return _fetch();
}
Future<void> reload() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_fetch);
}
Future<User> _fetch() => ref.read(apiProvider).getMe();
}
AsyncValue.guard
で loading → data / error
を一手に管理。
FSM の「遷移図」をコードでそのまま表現できます。
3‑3. マルチステップ FSM — オンボーディング
class OnboardingState with _$OnboardingState {
const factory OnboardingState.welcome() = _Welcome;
const factory OnboardingState.askName({String? name}) = _AskName;
const factory OnboardingState.askPrefs({required String name}) = _AskPrefs;
const factory OnboardingState.submitting() = _Submitting;
const factory OnboardingState.done() = _Done;
}
class OnboardingNotifier extends _$OnboardingNotifier {
OnboardingState build() => const OnboardingState.welcome();
void nextFromWelcome() => state = const OnboardingState.askName();
void submitName(String name) =>
state = OnboardingState.askPrefs(name: name);
Future<void> submitPrefs(List<String> prefs) async {
// submitting に遷移する前の現在の状態をキャプチャします
// これにより、送信が失敗した場合に正しい状態(名前を含む)に復帰できます。
final currentState = state;
if (currentState is! OnboardingState.askPrefs) {
// オプション: 予期しない状態を処理するか、エラーをログに記録します
// 簡単のため、ここでは単に return するか、例外をスローします。
// ロジックによっては、例外をスローすることを検討してください。
return;
}
state = const OnboardingState.submitting();
final api = ref.read(apiProvider);
try {
final ok = await api.savePrefs(prefs);
// 成功した場合は Done 状態に移行します。
// 失敗した場合は、キャプチャした 'AskPrefs' 状態に戻します。
state = ok ? const OnboardingState.done() : currentState;
} catch (e, st) {
// API呼び出し中の潜在的な例外を処理します
// 前の状態に戻し、エラー情報で状態を更新する可能性があります
// 簡単のため、ここでは前の状態に戻します。エラーステートの追加やロギングを検討してください。
ref.read(loggerProvider).error('Failed to save prefs', error: e, stackTrace: st);
state = currentState;
// オプション: 再スローするか、OnboardingState で定義されていれば特定のエラーステートを設定します
}
}
}
sealed
freezed
のパターンマッチ (when
, map
) と組み合わせると
UI 側は state.map(...)
だけで描画分岐が書けて幸せ。
4. ハマりポイントと回避策
ハマりがち | 回避テク |
---|---|
「同じ値なのに rebuild される!」 |
freezed が自動実装する == はフィールド比較です。オブジェクト全体が copyWith で別インスタンスになっても、関心のあるフィールドが変わらなければ再描画は不要なはずです。ref.watch(provider.select((s) => s.someValue)) を使うと、someValue が == で比較して変化した場合のみリビルドされるようになり、無駄な再描画を抑制できます。 |
全部 FSM にしたくなる病 |
単純なカウンタや TextEditingController は Widgetのローカル変数で十分。過剰設計は禁物。 |
5. Riverpod × FSM がうれしい理由
- 予測可能 – 状態と遷移が列挙されるので「バグなのか仕様なのか」論争が減る
- テストしやすい – Notifier は DI されたサービスをモックに置き換えるだけ
- UI が薄くなる – Widget は "状態を読む+イベントを投げる" だけ
6. まとめ
- Notifier / AsyncNotifier + freezed だけで FSM は組める
-
==
比較とselect()
でパフォーマンスを意識 - 過剰設計を避けて "ちょうどいい粒度" を探そう
Riverpod は "DI もキャッシュもできる状態管理ツール"。FSM を上手に載せると
「どの画面でも同じ動きをしてくれる堅牢 UI」が手に入ります。
ぜひ手元のプロジェクトで試してみてください 🙌
Discussion