コードレビューをしていて、最も頭痛がする瞬間の一つが、「何でもかんでも `if-else` や `enum` のフラグ管理で汚染された非同期処理」を見たときだ。
「ローディング中」「成功」「エラー」といった画面の状態を、あちこちに散らばった `isLoading` や `hasError` といった真偽値の組み合わせで制御しているコードは、プロジェクトが成長した瞬間に必ず破綻する。あり得ない状態(例: `isLoading = true` かつ `hasError = true`)の温床になり、デバッグに膨大な時間を奪われる。
Dart 3で導入された `sealed class` と パターンマッチング (`switch` 式) は、この悪夢を根本から根絶するためにある。
今回は、フロントエンドの状態管理や複雑なAPI連携において、バグの起きる余地を型レベルで排除する「有限ステートマシン(FSM)」のクリーンな設計パターンを、Dartの内部挙動を踏まえながら伝授しよう。
—
なぜ `enum` と `if-else` では不十分なのか?
単なる `enum Status { idle, loading, success, error }` では、「状態に付随するデータ(Payload)」を持てない という致命的な欠点がある。
例えば、`Error` 状態のときには例外オブジェクトやスタックトレースを持たせたいし、`Success` のときにはパース済みのドメインモデルを持たせたい。
これを `enum` でやろうとすると、クラスのフィールドに nullable な変数を並べ立て、状態が `error` の時だけ `errorMessage` が非nullであることを「プログラマの性善説」で担保する地獄のコードが完成する。
Dartの `sealed class`(網羅的クラス)を使えば、「特定の状態の時にしか存在しえないデータ」を型として厳密に封じ込めることができる。さらに、コンパイラがすべての状態の分岐を強制(Exhaustiveness checking)するため、将来状態を追加したときにハンドリング漏れを絶対に起こさない。
—
プロダクションコード:型安全なFSMの設計
以下のコードは、Webフロントエンドや非同期APIクライアントで頻出する「データフェッチFSM」のプロダクション実装だ。
コンパイル時に型がどう解決され、ランタイムでどう安全に処理されるかを意識して読んでほしい。
import ‘dart:async’;
// =================================================================
// 1. 状態 (State) と イベント (Event) の定義
// =================================================================
/// 画面やコンポーネントのすべての状態を sealed class で網羅。
/// sealed により、このライブラリ(ファイル)外での勝手なサブクラス化を禁止し、
/// switch 式での網羅性チェック(Exhaustiveness)をコンパイラに強制する。
sealed class FetchState
const FetchState();
}
class Initial
const Initial();
}
class Loading
const Loading();
}
class Success
const Success(this.data);
final T data;
}
class Failure
const Failure(this.error, this.stackTrace);
final Object error;
final StackTrace stackTrace;
}
/// 状態を遷移させるためのトリガー(イベント)
sealed class FetchEvent
const FetchEvent();
}
class FetchRequested
const FetchRequested();
}
class FetchRetried
const FetchRetried();
}
class FetchDataReceived
const FetchDataReceived(this.data);
final T data;
}
class FetchErrorOccurred
const FetchErrorOccurred(this.error, this.stackTrace);
final Object error;
final StackTrace stackTrace;
}
// =================================================================
// 2. ステートマシン (FSM) のコアロジック
// =================================================================
class FetchStateMachine
FetchStateMachine(this._fetchData);
// 実際の非同期データ取得処理(依存性注入)
final Future
// 内部の状態保持ストリーム
final _stateController = StreamController
Stream
FetchState
FetchState
/// イベントを受け取り、現在の状態と組み合わせて厳密な遷移を行う
void dispatch(FetchEvent
// Dart 3 のパターンマッチング(switch 式)による状態遷移マトリクス
// 許可されていない遷移(例: Success状態からの理不尽なイベント)はここで弾くか無視する
final nextState = switch ((_currentState, event)) {
// 1. Initial または Failure からのフェッチ要求
(Initial() || Failure(), FetchRequested() || FetchRetried()) => _handleLoad(),
// 2. Loading 中のデータ受信 -> 成功
(Loading(), FetchDataReceived
// 3. Loading 中のエラー発生 -> 失敗
(Loading(), FetchErrorOccurred(error: var e, stackTrace: var s)) => Failure
// 4. すでに Success 状態のときの再フェッチ要求(リロード)
(Success(), FetchRequested() || FetchRetried()) => _handleLoad(),
// 5. その他、現在の状態において無効なイベントは、状態を変化させない(ガード)
(_, _) => _currentState,
};
if (_currentState != nextState) {
_currentState = nextState;
_stateController.add(_currentState);
print(‘State Transition: 🔄 -> ${_currentState.runtimeType}’);
}
}
/// 非同期処理を実行し、結果をイベントとして自身にフィードバックする内部メソッド
FetchState
// 副作用を伴う非同期処理のキック
_executeFetchAsync();
return const Loading();
}
Future
try {
// ネットワークリクエスト等のシミュレーション
final data = await _fetchData();
dispatch(FetchDataReceived(data));
} catch (error, stackTrace) {
dispatch(FetchErrorOccurred(error, stackTrace));
}
}
void dispose() {
_stateController.close();
}
}
// =================================================================
// 3. 実行確認用 main 関数
// =================================================================
void main() async {
// 疑似的なAPIコール(200ms後にデータを返す、あるいは失敗する)
var shouldFail = false;
Future
await Future.delayed(const Duration(milliseconds: 200));
if (shouldFail) throw Exception(‘Network Error 500’);
return ‘Dart 3 & FSM Payload Data 🚀’;
}
final fsm = FetchStateMachine
// 状態変更の購読
fsm.stream.listen((state) {
// ここでも switch 式による網羅的パターンマッチングが威力を発揮する
// UIの描画ロジックは完全に型安全に分岐される
switch (state) {
case Initial():
print(‘UI: 初期画面を表示しています’);
case Loading():
print(‘UI: ローディングスピナーを表示中…’);
case Success(:final data):
print(‘UI: データの描画 -> $data’);
case Failure(:final error):
print(‘UI: エラー画面を表示 -> $error’);
}
});
print(‘— 1回目のフェッチ要求 —‘);
fsm.dispatch(const FetchRequested());
// 非同期の完了を待つ
await Future.delayed(const Duration(milliseconds: 400));
print(‘\n— 2回目のフェッチ要求(今度はエラーにする) —‘);
shouldFail = true;
fsm.dispatch(const FetchRequested());
await Future.delayed(const Duration(milliseconds: 400));
fsm.dispose();
}
—
チーフアーキテクトが解説するコードの急所
上記のコードがなぜ「プロダクション品質」と言えるのか、その背景にあるDartの言語仕様とアーキテクチャの要点を3つに分けて解説する。
1. `switch ((_currentState, event))` によるタプル・パターンマッチング
Dart 3では、複数の変数をカンマで区切った「タプル(レコード)」形式で `switch` に渡し、一度にパターンマッチングできる。
これにより、「現在の状態が何であり、かつ、どのイベントが飛び込んできたか」という2軸の条件分岐を、綺麗で平坦なマトリクスとして記述できる。
`if (state is Loading && event is FetchDataReceived)` のような、読みにくく拡張性の低いボイラープレートコードとはおさらばだ。
2. コンパイル時の網羅性チェック(Exhaustiveness Checking)
`sealed class` を使っているため、仮に将来 `Maintenance`(メンテナンス中)という新しい状態を `FetchState` に追加した場合、コード内のすべての `switch (state)` 式(UI側やFSM側)でそのハンドリングが漏れていると、コンパイルエラーになる。
「新しい状態を追加したのに、特定の画面でローディングが消えずにフリーズする」といった、本番環境特有のヒューマンエラーをコンパイラが完全にシャットアウトしてくれる。
3. パターン変数バインディング (`Success(:final data)`)
オブジェクトのプロパティにアクセスする際、従来の `(state as Success).data` のような危険なキャスト(型変換)は一切不要だ。
パターンマッチングの構文糖衣(Destructuring)により、マッチした瞬間に `data` や `error` が型安全に抽出され、スコープ内でそのまま利用できる。
—
パフォーマンスとメモリ効率に関する注意点
アーキテクチャを美しくしても、Dart VMの挙動を無視した実装をすればパフォーマンスが劣化する。以下の2点を心に留めておいてほしい。
1. `const` コンストラクタの徹底
`Initial` や `Loading` のようなペイロードを持たない状態クラスは、必ず `const` コンストラクタを定義し、インスタンスを使い回せ(Canonicalization)。毎回の状態遷移で不要なオブジェクトをヒープ領域にアロケートし、GC(ガベージコレクション)に負担をかける愚は犯してはならない。
2. ブロードキャスト・ストリームのライフサイクル管理
FSM内部で持つ `StreamController.broadcast()` は、UIコンポーネントが破棄されるタイミング(Flutterなら `dispose()`)で確実に `fsm.dispose()` を呼び出してクローズすること。さもないと、画面が消えた後もメモリリークを引き起こす原因になる。
—
まとめ
コードレビューで「状態管理が複雑化している」という指摘を受けたら、その場で `if` 文を削るのではなく、「状態とイベントの型定義」を見直せ。
Dart 3の `sealed class` とパターンマッチングを武器にすれば、状態遷移は「制御構造」ではなく「数学的なマトリクス」として美しく表現できる。バグが入り込む隙のない、堅牢で拡張性の高いシステムを君のプロダクトに導入してほしい。