Why StateObject’s need to be marked as private in SwiftUI?🤔

Gobi Rajesh Kumar N
7 min readApr 9, 2024
Why StateObject's need to be marked as private in SwiftUI?🤔

This article is inspired from various articles regarding Why StateObject’s need to be marked as private, so out of curious i dig deep into the apple documentation.

In Apple’s documentation about StateObject:

Declare state objects as private to prevent setting them from a memberwise initializer, which can conflict with the storage management that SwiftUI provides.

Even for a configurable state object, you still declare it as private. This ensures that you can’t accidentally set the parameter through a memberwise initializer of the view, because doing so can conflict with the framework’s storage management and produce unexpected results.

Here is a piece of code where we use StateObject without declaring it as private,

 
struct MyModelView: View {

@StateObject var model: DataModel

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

what’s wrong in this? let’s revisit Apple’s documentation about the usage of StateObject. Here are my findings on this topic.

3 Scenarios of using StateObject

There are typically 3 scenarios we may use StateObject.

1. Build your StateObject inside your view

This is the ideal situation where your view has the knowledge to build its StateObject . Apple used to only suggest this scenario to use StateObject.

struct MyInitializableView: View {

@StateObject private var model = DataModel()
}

2. Build your StateObject using external data

This is the scenario that Apple added to its document, Apple suggests developers use init(wrappedValue:) to create a state object.

struct MyInitializableView: View {

@StateObject private var model: DataModel


init(name: String) {
// SwiftUI ensures that the following initialization uses the
// closure only once during the lifetime of the view, so
// later changes to the view's name input have no effect.
_model = StateObject(wrappedValue: DataModel(name: name))
}

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

3. Inject StateObject via external

The scenario is not covered by Apple’s document yet. This is a typical Dependency Injection scenario where we inject a view model into its view. A view owns its view model, so StateObject is an ideal choice to define a view model. A view model usually has a networking service or storage service to define presentation and business logic. But we want to decouple these contexts from our SwiftUI view, which means our SwiftUI views don't have the knowledge to build its view model internally

struct MyModelView: View {
@StateObject var viewModel: DataModel
}

For Scenarios 1 and 2, it is straightforward because we build the StateObject internally, so there is no reason not to mark it as private.

Now, let’s focus on scenario 3.

How to inject a StateObject properly?

Firstly, how can we inject our view model if we make it private? You may think that we can just call the init(wrappedValue:) to create a state object using the passed instance. Let’s check out the following sample code. The result are very surprising!

final class DataModel: ObservableObject {

var name: String

init(name: String) {
self.name = name
print("model init \(name)")
}
}

struct StateObjectTestView: View {

@State private var timer: Timer?
@State private var timerText: Int = 0

var body: some View {
VStack(spacing: 10) {
Text("Seconds: \(timerText)")
.font(.body)
MyModelView(model: DataModel(name: "New Name"))
}
.onAppear {
timer = .scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.timerText += 1
}
}
}
}


struct MyModelView: View {

@StateObject private var model: DataModel

init(model: DataModel) {
_model = StateObject(wrappedValue: model)
}

var body: some View {
Text("Name: \(model.name)")
.font(.body)
}
}

In the above code, we make our model private in StateObjectExampleView , and we inject it via init - _model = StateObject(wrappedValue: viewModel) . It looks alright, but when we run the code, DataModel gets recreated every time when the timer is updated. Here is what it prints in the debug console.

model init New Name
model init New Name
model init New Name
...

That’s not what we expect from an StateObject at all. What's going on here?

If we check out the init(wrappedValue:) document, you will notice the wrappedValue is an @autoclosure to achieve a delayed init. SwiftUI will then make sure the @autoclosure only gets called once during the lifetime of the containing view. Passing an instance breaks the delayed init mechanism of @autoclosure, because your instance always gets initialised immediately before it reaches the wrappedValue @autoclosure .

To make this work, we must delay the initialization of our instance to make sure it reaches the safe hand of init(wrappedValue:)’s @autoclosure before it gets initialised. Then we have this solution for our MyModelView,

struct MyModelView: View {
@StateObject private var model: DataModel

init(model: @autoclosure @escaping () -> DataModel) {
_model = StateObject(wrappedValue: model())
}
}

Using protocols:


protocol DataModelProtocol: ObservableObject {
var name: String { get set }
}

struct MyModelView<T: DataModelProtocol>: View {

@StateObject private var model: T

init(model: @autoclosure @escaping () -> T) {
_model = StateObject(wrappedValue: model())
}
}

Will cover the advantages and disadvantages of using protocol later.

let’s continue, by using @autoclosure @escaping , we ensure to delay the DataModel 's init and hand it over to wrappedValue's autoclosure safely. Now, our debug console will only print model init New Name no matter how many times the timer is updated(ie: cause rerendering) in our view.

model init New Name

The simplest solution for injection

So the simplest version to inject an StateObject in scenario 3 is:

struct MyModelView: View {
@StateObject var viewModel: DataModel
}

But..it is not private! Yes, it is not. But, by exposing the view model, we get all the @autoclosure @escaping syntax sugar for free!

If we only do this dependency injection once, then we need to opt for private access level and use @autoclosure @escaping explicitly.

Mostly in projects, where we need to inject a view model into a SwiftUI view every time we build the view.

Why Apple only suggests private access?

Let’s again check Apple’s documentation of StateObject. Why does Apple only suggest private state objects?

Declare state objects as private to prevent setting them from a memberwise initializer, which can conflict with the storage management that SwiftUI provides.

If you don’t make it private, it conflicts with a memberwise initializer. Let’s check,

final class DataModel: ObservableObject {

var name: String

init(name: String) {
self.name = name
print("model init \(name)")
}
}

struct StateObjectTestView: View {

var body: some View {
VStack(spacing: 10) {
Text("My Model View")
MyModelView(model: DataModel(name: "New Name"))
}
}
}

struct MyModelView: View {

@StateObject var model = DataModel(name: "My Name")

var body: some View {
Text("Name: \(model.name)")
.font(.body)
}
}

Which DataModel will be used in MyModelView ? The default "My Name" or the memberwise initialized "New Name"?

Inside your MyModelView , you would expect "My Name" view model will be used as it is initialized as the default value of the view model. But because it is not private, the memberwise initializer can unexpectedly reset it to "New Name". Here is what it prints in the console,

model init New Name

Let’s go little further:

What if we want to reinitialize a state object when a view input changes.

MyModelView(name: name)

Does this work, no the view model will not have the updated name, so the next question what this does it will pass down to the my model view and rerender the view every time the name is updated without affecting the state object. Here is what it prints in the debug console.

model init
MyModelView: @self, @identity, _model changed.
MyModelView: @self changed.
MyModelView: @self changed.
...

How to make this work,

In Apple’s documentation:

To reinitialize a state object when a view input changes, Make sure that the view’s identity changes at the same time.

One way to do this is to bind the view’s identity to the value that changes using the id(_:) modifier. For example, you can ensure that the identity of an instance of MyModelView changes when its name input changes,

MyModelView(name: name)
.id(name) // Binds the identity of the view to the name property.

Here is what it prints in the debug console.

model init a
MyModelView: @self, @identity, _model changed.
model init ab
MyModelView: @self, @identity, _model changed.
model init abc
MyModelView: @self, @identity, _model changed.
...

In the above print statements we can see the view identity also changed the each time the name is updated.

If we want the view to reinitialize state based on changes in more than one value, you can combine the values into a single identifier using a Hasher. thus the data model is updated in MyModelView when the values of either name or isEnabled changes,

var hash: Int {
var hasher = Hasher()
hasher.combine(name)
hasher.combine(isEnabled)
return hasher.finalize()
}
MyModelView(name: name, isEnabled: isEnabled)
.id(hash) // Binds the identity of the view to the name and isEnabled property.

Be mindful of the performance cost of reinitializing the state object every time the input changes. Also, changing view identity can have side effects like animating and changing the identity resets all state held by the view.

Conclusion

  • For scenarios 1 and 2, we should declare all StateObject properties as private.
  • For scenario 3, the best option would be adding @autoclosure @escaping manually or we can also expose the StateObject directly and initiate it directly from external without adding the complex @autoclosure @escaping manually. The second option is particularly true for view models where we inject for every view.
  • And one more thing: Always read the Apple documentation 😁.

--

--