Flutterアプリを設計するとき、状態管理の粒度でつまずくエンジニアは少なくありません。
アコーディオンの開閉フラグ1つのために、イベントクラスやStream配線を用意するのは過剰です。逆に複雑な決済フローを素朴なミュータブル変数だけで組むと、競合状態や追跡不能な変更が後から発覚します。
この記事は、BLoC(Business Logic Componentの略で、状態をイベント駆動で管理するFlutterの設計パターン)を使い慣れたエンジニアや、Riverpod・Providerからの移行を検討しているチームに向けた内容です。BlocSignalという新しいライブラリが提示する「4段階の状態階層」を軸に、Bloc・Cubit・Signalをどう使い分けるか整理します。
なぜ今この整理が必要か
BlocSignalは、Felix Angelov氏が作ったBLoCアーキテクチャの規律と、Rody Davis氏によるPreact Signals(Reactが参考にした軽量リアクティブプログラミングの仕組み)のエンジンを組み合わせたライブラリです。
従来のSignal(値の変化を自動的に検知して依存先を更新する仕組み)は、FlutterのValueNotifierと同様、複数の値を組み合わせる合成が苦手という課題がありました。BlocSignalはこの合成問題を解決しつつ、状態更新をマイクロタスクキュー(非同期処理を後回しにする待ち行列)を経由せず同期的に0ミリ秒で伝播させる点が特徴です。
この「同期反映」と「BLoCの型安全な規律」が両立したことで、状態管理を1つのフレームワークに固定する必要がなくなりました。逆に言えば、開発者は状態の性質に応じて道具を選び分ける判断力が求められます。
判断軸1: 状態のスコープ(widget内かfeatureドメインか)
まず確認すべきは、その状態が単一widgetの内部で完結するか、それとも画面をまたぐfeatureロジックかという点です。
アコーディオンの開閉、ドロップダウンの表示、請求額の動的計算のような値は、他のwidgetから参照される必要がありません。こうしたケースではsignal()やcomputed()といった生のSignalプリミティブで十分です。クラスの雛形が不要で、widgetがアンマウントされれば自動的にガベージコレクションされます。
一方、CRUD操作を伴うfeatureロジック(例: ユーザープロフィールの取得・更新)は、複数のUI箇所から呼ばれる可能性があります。この規模になると生のSignalでは責務の境界が曖昧になりがちです。
判断軸2: 同時実行制御が必要か
2つ目の軸は「同じ処理が重複して走ったら困るか」です。
BLoCには、イベントの同時実行方法を制御する仕組み(droppable・sequential・restartableなど)があります。たとえば検索ボックスの入力ごとにAPIを叩く場合、最新の入力だけを反映したいのでrestartable(実行中の処理を破棄して最新のイベントだけ処理する方式)を使う場面が典型です。
Cubit(BLoCからイベントクラスを取り除き、メソッド呼び出しで直接状態を更新する軽量版)にはこの同時実行制御がありません。単純な非同期処理の直列実行で十分なら、Cubitの方がボイラープレート(毎回書く定型コード)が少なく済みます。
判断軸3: 永続化・Undo/Redoの要否
3つ目は、アプリ再起動をまたいだ状態の保持や、操作履歴の巻き戻しが必要かという軸です。
BlocSignalの階層では、この用途にHydratedMixin(状態を自動的にローカルストレージへ保存・復元する仕組み)やReplayMixin(操作履歴を保持しUndo/Redoを可能にする仕組み)が最上位に位置づけられています。これらはBLoCまたはCubitに追加するmixin(機能を後から付け足す仕組み)であり、単独の選択肢ではありません。
ドラフト保存が必要なフォームや、下書きエディタのUndo機能などがこの層に該当します。
判断軸4: テストの厳密さとチームの規約
最後の軸はテスト容易性です。BLoCはイベントと状態の対応が明示的なため、bloc_testのようなテストパッケージで「このイベントを与えたらこの状態列になる」という検証がしやすくなります。
CubitやSignalはメソッド呼び出しが直接状態を変えるため記述はシンプルですが、複雑な分岐が増えるとテストケースの網羅がやや読みにくくなる場合があります。チームの規約でイベントログの追跡性を重視するなら、多少冗長でもBLoCを選ぶ理由になります。
選択肢の比較
| 選択肢 | 適したスコープ | ボイラープレート | 同時実行制御 |
|---|---|---|---|
| Raw Signal / computed() | widget内の一時的な値・派生計算 | ほぼなし | 不要 |
| CubitSignal | featureドメインのCRUDロジック | 少ない | 基本なし |
| BlocSignal (Bloc) | 重要な業務フロー・並行処理 | やや多い | droppable等で細かく制御 |
| Hydrated/Replay Mixin | 永続化・Undo/Redoが必須な状態 | Bloc/Cubitに追加 | 継承元に依存 |
ケース別の推奨
- モーダルの開閉やホバー状態だけを扱うなら、生のSignalを選びます。クラス定義すら不要です
- ユーザー設定画面のような単純な取得・更新フローなら、CubitSignalで直接メソッドを呼ぶ設計が読みやすくなります
- 決済処理や検索のように、リクエストの競合や中断を明示的に扱う必要があるなら、BlocSignal(イベント駆動のBLoC)を選びます
- 下書き保存やUndo履歴が要件に含まれるなら、選んだBLoCまたはCubitに
HydratedMixinやReplayMixinを重ねます
あえて見送るべき条件
すべての状態をBLoCに寄せるのは避けた方が無難です。単純なUIトグルにイベントクラスとsealedな階層を用意すると、可読性がむしろ落ちます。
逆に、複数フォームの入力値を横断的に検証するような複雑な業務ロジックを、素のSignalだけで組むのもリスクがあります。依存関係が増えると、どのSignalがどこで更新されているか追跡しづらくなるためです。
また、既存プロジェクトがRiverpodやProviderで安定稼働している場合、BlocSignalへの一括移行を急ぐ必要はありません。段階的な移行(一部featureだけ置き換える)が可能かどうかは、公式リポジトリのマイグレーションガイドで移行パスが用意されているか確認してから判断するのが安全です。
確認すべきポイント
実際に導入を検討する場合は、まず自分たちのアプリの状態を「widget内・featureドメイン・業務クリティカル・永続化」の4段階に仕分けてみるのが第一歩です。
既存のBLoCコードがある場合は、BlocSignalがBLoCのAPIとの互換性をどこまで保っているか、パッケージのCHANGELOGやテストカバレッジを確認します。
新規プロジェクトであれば、まず小さな画面のトグル状態だけ生のSignalに置き換えてみて、computed()の挙動やwidget再構築のタイミングを実際に確認するのが手堅い進め方です。
まとめ
状態管理の選択は「一律にどれか1つを使う」ものではなく、状態の性質ごとに使い分けるものだと整理できます。
- widget内の一時的な値には生のSignal、featureのCRUDにはCubit、並行処理を伴う業務フローにはBLoCという住み分けが基本です
- 永続化やUndo/Redoが必要なら、選んだ状態コンテナにmixinを追加する形で対応します
- 既存プロジェクトを一気に移行する前に、公式の互換性情報と移行パスを確認しておくと安全です
まずは自分のアプリの画面を1つ選び、そこにある状態を4段階のどこに位置づけられるか棚卸しするところから始めてみてください。