🤖

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 の再描画判定は == だということ。identicalupdateShouldNotify は登場しません。


2. ステートマシンって何?

「状態を全部列挙して、イベントで遷移を定義する」だけ──仕組みはシンプルですが、UI でありがちな「ありえない状態」を撲滅する強力なパターンです。

2‑1. 用語ざっくり

用語 意味 UI での例
State システムが取りうる一つひとつの "居場所" Loading / Success / Error
Event 状態を変える入力 (クリックや API の応答) 「再読み込みボタンを押した」
Transition State A ➡️ State B への経路 LoadingSuccess
Action / Side‑Effect 遷移時に走る処理 API を叩く / ログを送る

これだけ覚えれば 8 割は OK 😀

2‑2. 3 ステップで考えるフロー

  1. 状態を列挙する: まずは付箋に全部書き出す
  2. イベントを洗い出す: ボタン、タイマー、外部通知…
  3. 線でつなぐ: 「このイベントが来たらどこへ行く?」

例として ロード付きリスト画面 を図にすると:

ポイント

  • 状態は 排他的LoadingLoaded が同時に存在することはない
  • 新しい機能は "状態 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 の状態定義)と非常に相性が良いです。freezedsealed を用いることで以下のメリットが得られます。

  • 網羅性チェック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.guardloading → data / error を一手に管理。
FSM の「遷移図」をコードでそのまま表現できます。


3‑3. マルチステップ FSM — オンボーディング


sealed 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 で定義されていれば特定のエラーステートを設定します
    }
  }
}

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」が手に入ります。
ぜひ手元のプロジェクトで試してみてください 🙌

合同会社CAPH TECH

Discussion