2023 update: Since I've wrote this article Apple published some guidance regarding this topic. However, I highly recommend avoiding tampering with the identity of a view as they suggest to do for initializing @StateObject
. Doing so may lead to undesirable consequences such as malfunctioning animations, potential loss of focus, performance issues, and various other issues that you definitely wouldn't want to troubleshoot.
The problem
Before we start
There's a subtle difference between the following inits. Both are initializing @State
, however,
since the first one doesn't rely on an external value for the greeting, it's not a problem per se.
Just the same as using @State private var greeting: String = "Hello John"
(and has the same
limitation when it comes to external state injection too).
// Maybe an anti-pattern, but this is not a problem per se
struct SimpleView: View {
@State private var greeting: String
init() {
_greeting = State(initialValue: "Hello John")
}
//...
}
// This **is** a problem tho
struct SimpleView: View {
@State private var greeting: String
init(name: String) {
_greeting = State(initialValue: "Hello \(name)!")
}
//...
}
There seems to be some confusion around the @State
property wrapper. So much so the top answers in all these StackOverflow questions are wrong.
TL;DR You never initialize @State
inside the view's init
. Use @Binding
, @ObservedObject
, a combination between @Binding
and @State
or even a custom DynamicProperty
(e.g. for edit and commit/save later, here's a good example).
☝🏽 That's the straightforward(ish) solution. But there's more to learn here! This can give us a solid insight into how @State
and other DynamicProperty
wrappers work.
Let's start with an example.
struct SimpleView: View {
@State private var greeting: String
init(name: String) {
_greeting = State(initialValue: "Hello \(name)!")
}
var body: some View {
VStack {
Text(greeting)
Button(action: { greeting = "Hello Tim!" }) {
Text("Update greeting")
}
}
}
}
struct ContentView: View {
@State private var name: String = "Mark"
var body: some View {
VStack {
SimpleView(name: name)
Button(action: { name = "Summer" }) {
Text("Update name")
}
}
}
}
If you run this code you'll notice that there's no way to make Hello Summer
show up. That's weird since the name
member in ContentView
clearly holds Summer
once we press the update name button.
Now, let's add some custom content with Summer's name:
if name == "Summer" {
HStack {
SimpleView(name: name)
Text("☀️")
}
}
else {
SimpleView(name: name)
}
Everything works now. At least the first time we press the update name button. But then, it fails just like before. Looks like undefined behavior, but it's not, there is a reason behind this, and it all boils down to a State
initialization misuse.
The conditional view here was just an excuse, anything that would have create a SimpleView
with a different structural identity would have worked.
Now, you might be tempted to use State(initialValue:)
as long as you only pass constant values to the init
method (e.g. SimpleView(name: "Enter name")
). This would work, but needless to say, it's bad practice since it creates an unenforceable contract between the provider and the consumer of the API (in other words, it's really easy to mess up and assume a change in name
should be reflected in SimpleView
)
Inconsistent behavior is certainly bad for business, but if none of the above convinced you, I'll leave here a comment by one of Apple's compiler engineers:
Any consistent behavior you see is an accident. @State's initial value must be independent of any instance of the view. Chances are good this will be enforced at compile time when the necessary property wrapper functionality exists to do so.
And another one, here:
Although that will compile, @State variables in SwiftUI should not be initialized from data you pass down through the initializer; since the model is maintained outside of the view, there is no guarantee that the value will really be used. The correct thing to do is to set your initial state values inline:
@State private var viewModel = SignInViewModel(...)
Join us
What causes this behavior
The answer can be found in DynamicProperty
's documentation. @State
like many other stateful wrappers (e.g. @EnvironmentObject
, @AppStorage
, @StateObject
and so on) conforms to this obscure protocol. You won't find much about it, but it's at the core of SwiftUI's magic. The overview in the docs mention:
The view gives values to these properties prior to recomputing the view’s body.
Views are always immutable, so any stateful member like greeting
has to live somewhere else. When using a DynamicProperty
we're telling SwiftUI the member/property needs external storage. And once that external storage is allocated for our property, SwiftUI will always read from it and assign its value to our actual view member before recomputing the body.
But wait, how can it assign a value to an immutable view?
That's the magic part. It's probably done by manipulating the actual memory representation of
the View
instance. The fun part is that if you create a custom DynamicProperty
you can have a
let property: MyDynamicProperty
member and SwiftUI will still be able to inject the external
value before recomputing the body. I'm not sure if this is a bug or intended, but it completely
bypasses the type system.
In our case, using _greeting = State(initialValue: "Hello \(name)!")
works the first time because it allocates the external storage for the view (identified by its structural identity), but fails each subsequent time since SwiftUI ignores any re-allocations (for the same identity) and updates the actual view property with the value it finds in the external storage right before recomputing the body.
StackOverflow answers
- https://stackoverflow.com/questions/56691630/swiftui-state-var-initialization-issue
- https://stackoverflow.com/questions/62635914/initialize-stateobject-with-a-parameter-in-swiftui
- https://stackoverflow.com/questions/58758370/how-could-i-initialize-the-state-variable-in-the-init-function-in-swiftui