@StateObject vs @ObservedObject — 使い分けの基準

SwiftUI で ObservableObject を扱う際、@StateObject と @ObservedObject という2つのプロパティラッパーが登場する。どちらもビューに変更を通知する役割を持つが、インスタンスの所有権とライフサイクルに決定的な違いがある。この違いを理解しないまま使うと、ビューの再描画時にデータが消えるという厄介なバグを招く。

根本的な違い:所有するか、借りるか

@StateObject はインスタンスを「所有」し、@ObservedObject はインスタンスを「借りる」。所有とは、そのビューがオブジェクトの生成と破棄に責任を持つということだ。借りるとは、外部から渡されたオブジェクトを参照するだけで、ライフサイクルの管理には関与しないことを意味する。

@StateObject

ビューが初めて描画されるときに一度だけインスタンスを生成し、ビューが破棄されるまで保持する。親ビューの再描画でも再生成されない。

@ObservedObject

インスタンスの生成・管理は行わない。親ビューが再描画されるたびに、渡されたインスタンスをそのまま受け取る。

@StateObject の挙動

@StateObject で宣言したプロパティは、ビューの body が何度呼ばれても同じインスタンスが維持される。SwiftUI が内部的にインスタンスをストレージに保持しているためだ。

struct ParentView: View {
    var body: some View {
        ChildView()
    }
}

struct ChildView: View {
    @StateObject private var model = CounterModel()

    var body: some View {
        Text("\(model.count)")
        Button("increment") {
            model.increment()
        }
    }
}

ParentView が何らかの理由で再描画されても、ChildView 内の model は再生成されない。カウンターの値はそのまま保たれる。

@ObservedObject で起きる問題

もし上のコードで @StateObject の代わりに @ObservedObject を使ったらどうなるか。

struct ChildView: View {
    @ObservedObject private var model = CounterModel()

    var body: some View {
        Text("\(model.count)")
        Button("increment") {
            model.increment()
        }
    }
}

一見動くように見えるが、ParentView が再描画されるたびに ChildView も再構築される。@ObservedObject はインスタンスを保持しないため、CounterModel() が毎回新しく生成され、カウンターの値が 0 に戻ってしまう。これが @ObservedObject を誤用したときに起きる典型的なバグだ。

正しい使い分けのルール

使い分けの基準は明確で、「そのビューがインスタンスを生成する側か、受け取る側か」で決まる。

生成する側 → @StateObject

ビュー内で直接インスタンスを作る場合は @StateObject を使う。init 内で生成する場合も同様。そのビューが「オーナー」になる。

受け取る側 → @ObservedObject

親ビューや外部から渡されたインスタンスを参照するだけなら @ObservedObject を使う。所有権は渡す側にある。

実際のコードでこのパターンを見てみよう。

struct ParentView: View {
    @StateObject private var model = CounterModel()

    var body: some View {
        VStack {
            Text("parent: \(model.count)")
            ChildView(model: model)
        }
    }
}

struct ChildView: View {
    @ObservedObject var model: CounterModel

    var body: some View {
        Button("increment from child") {
            model.increment()
        }
    }
}

ParentView が @StateObject でインスタンスを所有し、ChildView は @ObservedObject でそれを受け取る。ChildView からの変更は ParentView にも反映され、ParentView が再描画されても model は @StateObject によって保持される。

iOS 14 以前との互換性

@StateObject は iOS 14 で導入された。それ以前は @ObservedObject しか存在せず、インスタンスの再生成問題を避けるために親ビューで生成して渡すパターンが必須だった。

iOS 13
@ObservedObject のみ

ObservableObject を監視する手段は @ObservedObject だけだった。ビュー内でのインスタンス生成は再描画時に消失するリスクがあった。

iOS 14
@StateObject 登場

インスタンスの所有権を明示できるようになり、ビュー内でのオブジェクト生成が安全になった。

現在のプロジェクトで iOS 14 以降をターゲットにしているなら、オブジェクトを生成する場所では必ず @StateObject を使うべきだ。@ObservedObject でのインスタンス生成は、意図しない再初期化の原因になる。

init でのカスタム初期化

@StateObject を init 内で初期化したい場合は、_model に直接 StateObject(wrappedValue:) を渡す。

struct ProfileView: View {
    @StateObject private var model: ProfileModel

    init(userId: String) {
        _model = StateObject(wrappedValue: ProfileModel(userId: userId))
    }

    var body: some View {
        Text(model.name)
    }
}

この書き方でも @StateObject の恩恵は受けられる。初回の描画時にのみ StateObject のクロージャが評価され、以降の再描画では同じインスタンスが再利用される。

判断に迷ったら

迷ったときは「このオブジェクトは誰が作ったのか」を考えればよい。自分で作ったなら @StateObject、誰かからもらったなら @ObservedObject。この原則を守るだけで、データ消失のバグはほぼ防げる。