ObservableObject protocol @StateObject
part 1
What is @StateObject
?
In SwiftUI, @StateObject
is used to create and manage an instance of a class that conforms to the ObservableObject
protocol. This property wrapper ensures that the object is persisted across view updates, and that the view will re-render when properties within the object change.
@StateObject
is designed for objects that are owned by the view, meaning that the view is responsible for the lifecycle of the object.- When the object changes (because of
@Published
properties), the SwiftUI view will automatically re-render.
Key Concepts of @StateObject
:
Used for Observable Objects:
- The object being managed must conform to the
ObservableObject
protocol. - This protocol requires the object to have an
objectWillChange
publisher, which notifies SwiftUI before any changes occur in the object’s properties.
Persists Across View Updates:
- The object managed by
@StateObject
persists across view updates and is not recreated when the view is re-rendered. - This differs from
@State
, which is used for simple value types (e.g.,Int
,Bool
,String
) rather than objects.
ObservableObject
Protocol and @Published
Properties:
To make an object observable, it needs to conform to the ObservableObject
protocol. Typically, the object will have properties marked with the @Published
property wrapper, which ensures changes to these properties will trigger the objectWillChange
publisher and notify SwiftUI to update the view.
Here’s an example:
final class Model: ObservableObject {
@Published var value = 0
}
In the example above:
- The
Model
class conforms toObservableObject
, and itsvalue
property is marked as@Published
. - Whenever
value
changes, the view will be notified and will re-render.
How @StateObject
Works in SwiftUI:
Let’s use the following example to illustrate the role of @StateObject
in a SwiftUI view:
struct Counter: View {
@StateObject private var model = Model() // Create and manage an observable object
var body: some View {
Button("Increment: \(model.value)") {
model.value += 1 // Increment the value
}
}
}
Creating the Object:
- The
@StateObject
property wrapper creates an instance ofModel
, and SwiftUI keeps track of this object for the lifetime of the view. - The
model
object is owned by theCounter
view, and SwiftUI manages its lifecycle.
ObservableObject and View Re-renders:
- The
model.value
property is used inside theButton
label, so SwiftUI tracks that the view depends onmodel.value
. - When the button is pressed,
model.value
is incremented, which triggers theobjectWillChange
publisher and notifies SwiftUI to re-render the view. - SwiftUI re-renders the view to reflect the new value of
model.value
.
Behind the Scenes of @StateObject
:
When you declare @StateObject
, SwiftUI automatically generates some additional code to manage the object's state and track its changes.
Without Using Property Wrapper Syntax:
The @StateObject
property wrapper is syntactic sugar, and without it, the code would look like this:
struct Counter: View {
private var _model = StateObject(wrappedValue: Model())
private var model: Model { _model.wrappedValue }
var body: some View {
Button("Increment: \(model.value)") {
model.value += 1
}
}
}
_model
: This is the underscored variable that holds theStateObject
instance, managing the lifecycle of the observable object.model
: This is a computed property that accesses thewrappedValue
of theStateObject
, which is the actualModel
object.
objectWillChange
Publisher:
The ObservableObject
protocol requires the class to implement the objectWillChange
publisher, which informs SwiftUI about changes before they happen.
- By using
@Published
, you automatically trigger theobjectWillChange
publisher whenever a property marked with@Published
changes. - If you want to manually trigger the publisher, you can override the
willSet
property observer to callobjectWillChange.send()
.
final class Model: ObservableObject {
var value = 0 {
willSet {
objectWillChange.send() // Notify SwiftUI that a change will occur
}
}
}
This is the manual version of what @Published
does behind the scenes.
The View Lifecycle and @StateObject
:
Here’s a step-by-step breakdown of what happens when you use @StateObject
:
Initialization:
- The view is created, but the
@StateObject
instance (model
) is not initialized yet. - The
StateObject
holds the initial value as anautoclosure
, meaning it will create the object when the render tree node is created.
Object Creation:
- When the render tree node (for the
Counter
view) is created, theStateObject
is initialized, and theModel
object is stored in the render tree. - The view uses this
Model
object to render itself.
View Re-Renders:
- When the button in the view is tapped,
model.value
is updated. - The
@Published
property triggersobjectWillChange
, which notifies SwiftUI that the view needs to update. - SwiftUI re-executes the view’s body and updates the
Button
label to reflect the new value.
Persistence Across Re-renders:
- The
Model
instance managed by@StateObject
persists across view updates and is not re-created unless the view is completely removed and recreated.
Common Mistakes:
Passing Objects from Outside:
@StateObject
should not be used for objects passed into the view from the outside (e.g., from a parent view). For externally passed objects, use@ObservedObject
. This doesn’t work for the same reason when talking about @State: when the view’s initializer runs, the view doesn’t yet have identity. As a rule of thumb, we should only use @StateObject when we can assign an initial value to the property on the line where we declare the property,
- Wrong:
struct ParentView: View {
@StateObject var model: Model
var body: some View {
Counter(model: model) // Do not use @StateObject here
}
}
Correct:
struct Counter: View {
@ObservedObject var model: Model // Use @ObservedObject for externally managed objects
// ...
}
Initializing State in the Initializer:
- Avoid creating a
@StateObject
in the view’s initializer. Instead, assign the initial value directly when declaring the property.
Wrong:
struct Counter: View {
@StateObject var model: Model
init() {
self.model = Model() // Do not initialize in the init method
}
}
Correct:
struct Counter: View {
@StateObject var model = Model() // Initialize when declaring
}
Example: Issues with Passing @stateobject
in initializer
In this example, the goal is to create a counter that tracks different values for multiple rooms (e.g., Hallway, Living Room, Kitchen). Each room has its own separate CounterModel
, which is responsible for holding the count for that specific room.
However with @StateObject
the count value is shared across all rooms as you can see in the snapshot below.
import SwiftUI
// ObservableObject class
class CounterModel: ObservableObject {
@Published var value = 0
}
// External model manager
class Model {
static let shared = Model()
var counters: [String: CounterModel] = [:]
func counterModel(for room: String) -> CounterModel {
if let model = counters[room] {
return model
}
let newModel = CounterModel()
counters[room] = newModel
return newModel
}
}
// View that observes an external object
struct Counter: View {
@StateObject var model: CounterModel // Observing the passed model
var body: some View {
Button("Increment: \(model.value)") {
model.value += 1
}
}
}
// ContentView that passes the model to Counter
struct ContentView: View {
@State private var selectedRoom = "Hallway"
var body: some View {
VStack {
Picker("Room", selection: $selectedRoom) {
ForEach(["Hallway", "Living Room", "Kitchen"], id: \.self) { room in
Text(room).tag(room)
}
}
.pickerStyle(.segmented)
Counter(model: Model.shared.counterModel(for: selectedRoom))
}
}
}
Why the Same Value Is Shared with @StateObject
:
The @StateObject property wrapper works much in the same way as @State: we specify an initial value (an object in this case), which will be used as the starting point when the node in the render tree is created. From then on, SwiftUI will keep this object around across rerenders for the lifetime of the node in the render tree.
Similarly to above in the example:
@StateObject
is designed to own and manage the lifecycle of an object: When you use@StateObject
in a SwiftUI view, SwiftUI expects this view to create and maintain the lifecycle of the object. It assumes that this view is the only owner of the object.- You are passing an object from the outside: In your case, you are passing an object from a shared external model (
Model.shared.counterModel(for:)
). So, each time theCounter
view is re-rendered,@StateObject
doesn't reinitialize the object as expected. Instead, it uses the already existing instance that’s being shared by the external model when the first time it was created as the object is kept around across rerenders. This causes all counters to point to the same object, so changing the value in one room’s counter will also affect the others and update at the same time.
Why This Doesn’t Happen with @ObservedObject
:
for more details you can go to part 2
@ObservedObject
just observes an object without managing its lifecycle: When you use@ObservedObject
, SwiftUI doesn’t assume that the view owns the object. It simply observes the object passed from the outside.- Each room gets its own
CounterModel
: When you switch rooms and pass a newCounterModel
instance from your sharedModel
,@ObservedObject
will observe the new instance. This means that eachCounterModel
is distinct for each room, and changes in one room won’t affect the others. - This is why
@ObservedObject
works in your case: it lets SwiftUI just observe theCounterModel
provided by the external model, ensuring that each room’s counter remains independent.
Summary:
@StateObject
is used to manage an observable object that is owned by a view. It persists across view re-renders and ensures the view updates when the object’s properties change.ObservableObject
is a protocol used to make a class observable by SwiftUI, typically with properties marked@Published
.- Use
@StateObject
for objects that are private to the view and need to persist across updates, while@ObservedObject
should be used for objects passed in from the outside.