Shall we make all StateObject properties private in SwiftUI?

MEGA
6 min readJun 21, 2023

--

By Simon Wang, Engineering Manager, Mobile, MEGA

We had an interesting discussion in our recent code review. An engineer raised a question: “StateObject properties are supposed to be private, why we are not doing this?”

In Apple’s StateObject document:

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 one piece of MEGA’s iOS code where we use StateObject without declaring it as private:

import SwiftUI

struct PhotoCell: View {
@StateObject var viewModel: PhotoCellViewModel
var body: some View {
PhotoCellContent(viewModel: viewModel)
}
}

That was a great code review question. To answer it, I double-checked Apple’s latest document and re-examined the usage of StateObject in our iOS project. Here are my findings and thoughts on this topic.

Context

When StateObject was first published on iOS 14, Apple asked engineers to avoid calling its init(wrappedValue:) directly. At that time, Apple only suggested building your StateObject inside your view. Last year, Apple changed its document to allow engineers to call init(wrappedValue:) but advised us to do so cautiously because the way it is used could generate unexpected side effects. However, Apple has not covered all scenarios of using StateObject.

3 Scenarios of using StateObject

There are typically 3 scenarios you 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 MyView: View {
@StateObject private var model = DataModel() // Create the state object.
}

2. Build your StateObject using external data

This is the scenario that Apple added to its document just last year. Along with it, Apple started suggesting 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) }())
}
}

3. Inject your StateObject from external

The scenario is not covered by Apple’s document yet. This is a typical Dependency Injection scenario in the MVVM pattern 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 and a 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.

struct MyToggleView: View {
@StateObject var viewModel: ToggleViewModel
}

For Scenarios 1 and 2, it is easy and 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 viewModel 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 can be very surprising!

struct StateObjectTestView: View {
@State private var isEnabled = false

var body: some View {
VStack {
Toggle(isOn: $isEnabled) {
Text(isEnabled ? "view is enabled" : "view is disabled")
}
MyToggleView(viewModel: ToggleViewModel(name: "new toggle"))
}
}
}

final class ToggleViewModel: ObservableObject {
var name: String
init(name: String) {
self.name = name
print("ViewModel init \(name)")
}
}

struct MyToggleView: View {
@StateObject private var viewModel: ToggleViewModel

init(viewModel: ToggleViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}

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

In the above code, we make our viewModel private in MyToggleView , and we inject it via init - _viewModel = StateObject(wrappedValue: viewModel) . It looks alright, but when we run the code, ToggleViewModel gets recreated every time when we tap the toggle view. Here is what it prints in the debug console.


ViewModel init new toggle
ViewModel init new toggle
ViewModel init new toggle
...

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

If you 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, you must delay the initialization of your 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 MyToggleView example:

struct MyToggleView: View {
@StateObject private var viewModel: ToggleViewModel

init(viewModel: @autoclosure @escaping () -> ToggleViewModel) {
_viewModel = StateObject(wrappedValue: viewModel())
}
}

Here, by using @autoclosure @escaping , we ensure we delay the ToggleViewModel 's init and hand it over to wrappedValue's autoclosure safely. Now, your debug console will only print ViewModel init new toggle no matter how many times you toggle your view.

The simplest solution for injection

Now, you may be wondering why this is so complex. It is indeed hard to understand, isn’t it? So the simplest version to inject an StateObject in scenario 3 is this:

struct MyToggleView: View {
@StateObject var viewModel: ToggleViewModel
}

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 I may vote for a private access level and use @autoclosure @escaping explicitly. However, we are using MVVM pattern. If you think SwiftUI is against MVVM pattern and people should never use MVVM for SwiftUI, then you can stop here and there is no need to read further.

With MVVM , we need to inject a view model into a SwiftUI view every time we build the view. It is not ergonomic and it is error-prone to write @autoclosure @escaping in every view just to trade for an private access level.

What are the risks of this solution?

Now we have exposed the StateObject viewModel , what risks could we be taking?

In SwiftUI, a view is a struct, once a view model is created, it prevents you from reassigning or changing its view model in a non-mutating SwiftUI view context.

So it is safe.

Why Apple only suggests private access

Let’s go back to Apple’s current document of StateObject. Why does Apple only suggest private state objects? I think there are two reasons:

1. Scenario 3 is not covered by Apple’s document yet

Scenario 2 was only covered by Apple’s document in 2022. I believe scenario 3 will be covered by Apple’s document in the near future. Apparently, not all objects can be built internally to a view. When we start splitting our code into different layers and components, it is very common to move the view model construction code away from its view.

2. For Scenarios 1 and 2 that are covered by Apple’s document, private StateObject should be applied

As Apple’s document says:

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 the following code snippet:

struct StateObjectTestView: View {
@State private var isEnabled = false

var body: some View {
VStack {
Toggle(isOn: $isEnabled) {
Text(isEnabled ? "view is enabled" : "view is disabled")
}
MyToggleView(viewModel: ToggleViewModel(name: "new toggle"))
}
}
}

final class ToggleViewModel: ObservableObject {
var name: String
init(name: String) {
self.name = name
print("ViewModel init \(name)")
}
}

struct MyToggleView: View {
@StateObject var viewModel = ToggleViewModel(name: "My toggle")

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

Which ToggleViewModel will be used in MyToggleView ? The default "My toggle" or the memberwise initialized "new toggle"?

Inside your MyToggleView , you would expect "My toggle" 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 toggle". Here is what it prints in the console:

ViewModel init new toggle

Conclusion

So, in conclusion:

  • For scenarios 1 and 2, we should declare all StateObject properties as private.
  • For scenario 3, we should expose it and initiate it directly from external without adding the complex @autoclosure @escaping manually. This is particularly true for MVVM pattern where we inject for every view.

--

--

MEGA

Our vision is to be the leading global cloud storage and collaboration platform, providing the highest levels of data privacy and security. Visit us at MEGA.NZ