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 forMVVM
pattern where we inject for every view.