画面の状態を1つ変えるだけなのに、if文を10個も修正した経験はありませんか?
ローディング・成功・失敗の状態をすべてif isLoadingやelse if hasErrorで処理していると、コードはすぐに手に負えなくなります。
今回は、実体験をもとに、SwiftのState Patternでif文地獄を状態オブジェクトに整理する方法を紹介します。
要点から言うと、State Patternとは各状態を別々のオブジェクト(またはenum)に分け、状態遷移のロジックをそのオブジェクト内に置くことです。条件分岐が散らばらず、1か所に集約できます。
構造がほぼ同じStrategy Patternとの違いは、誰が変更を主導するかです。Strategy Pattern vs. State Patternで、外部からの選択と内部の状態遷移を先に区別できます。
この記事でわかること
- なぜif文地獄が起きるのか
- Swiftで状態をオブジェクトに分ける2つの方法
- enumとprotocolのどちらをいつ使うか
- 実務ですぐ使えるリファクタリング手順
なぜif文地獄は起きるのでしょうか?
最初は状態が2つほどしかないので、ifで十分に見えます。しかし要件が増えると、状態は5つ、6つへ膨らみます。ローディング、成功、失敗、空画面、再試行中……といった具合です。
問題は、こうした状態の組み合わせが複数のメソッドに分散することです。ボタンの有効化ロジックにもif、画面更新にもif、ネットワークコールバックにもifが繰り返し現れます。そのため状態を1つ追加すると、そのifの塊をすべて探して修正する必要があり、1つでも漏れるとすぐバグになります。
状態が散らばるとバグは隠れ、状態を集めるとバグが見えるようになります。
State Patternは、まさにこの「分散」を防ぐための構造です。
Swiftで状態をオブジェクトに分ける方法(enum方式)
最も軽く始められる方法はenumです。状態を値として定義し、関連値で必要なデータも保持できます。以下は画面状態をenumで表現した例です。
enum ViewState {
case loading
case loaded(items: [String]) // 成功時のデータを添付
case failed(message: String) // 失敗理由を添付
case empty
}
これで画面更新はswitch一度で済みます。関連値のおかげで、「成功なのにデータがnil」という曖昧な状態自体が生まれません。
switch state {
case .loading: showSpinner()
case .loaded(let items): render(items)
case .failed(let msg): showError(msg)
case .empty: showEmptyView()
}
ほとんどの画面状態は、このenum方式で十分だと感じます。switchはケースの漏れをコンパイラが検出してくれるので、状態追加も安心です。
enum vs. protocol、いつ何を使う?
状態ごとの「振る舞い」が大きく異なり、遷移ルールが複雑ならprotocol方式が適しています。各状態を型として作り、次の状態を自ら返す構造です。2つの方式の違いをまとめると次のとおりです。
| 区分 | enum方式 | protocol方式 |
|---|---|---|
| 適した状況 | 状態がデータ中心 | 状態ごとに振る舞いが異なる |
| 状態の追加 | caseを追加 | 型を追加 |
| 遷移ロジック | 外部でswitch | 状態オブジェクト内にカプセル化 |
| 学習コスト | 低い | やや高い |
protocol方式の要点は、状態遷移を各オブジェクトが担当することです。
protocol PlayerState {
func play() -> PlayerState // 次の状態を返す
func pause() -> PlayerState
}
struct PlayingState: PlayerState {
func play() -> PlayerState { self } // 再生中ならそのまま維持
func pause() -> PlayerState { PausedState() } // 一時停止へ遷移
}
このようにすると、「再生中にpauseを押したら?」というルールは、その状態の中だけに存在します。分岐は消え、各状態は自分のルールだけを知ればよくなります。
実務でのリファクタリングはこの順番で
一度にすべて変えようとすると、かえって不安になります。私は次の順番で少しずつ移行しました。
- 散らばった
ifが実際に表している状態の一覧を紙に書く - その状態を1つのenumとして定義する
- まず画面更新コードを
switchに置き換える - ローディングとエラーが同時に起きるなど、不可能な状態の組み合わせをenumで取り除く
- 遷移ルールが複雑な部分だけをprotocolへ昇格する
ポイントは、enumから始め、必要なときだけprotocolへ昇格することです。最初からprotocolで過度に設計すると、かえってコードが重くなります。
よくある質問(Q&A)
Q. 状態が3つしかなくてもパターンを使うべきですか?
必ずしも必要ではありません。ただし状態が複数のメソッドに分散しているなら、数に関係なくenumにまとめるのがおすすめです。
Q. SwiftUIでも使えますか?
はい。状態を@Published var state: ViewStateの形で持ち、ビューでswitchによって分岐すると非常によく合います。
Q. enumの関連値が増えると複雑になりませんか?
関連値が3つを超えたら、別のstructにまとめてcase loaded(Result)のように保持することをおすすめします。
最初は慣れなくても、状態をオブジェクトにまとめる感覚をつかむと、以前のコードには戻りたくなくなるはずです。まずは小さな画面1つからenumで整理してみてください。思ったより早くif文地獄から抜け出せます。応援しています!

