All you never wanted to know about state in SwiftUI
I recently released an app written 100% in SwiftUI and in doing so, uncovered some patterns that are difficult to express. In this post, I am going to explain how to set initial state in SwiftUI. It’s trickier than you’d expect.
First, an overview
There are three types of local state in SwiftUI:
- State that’s only controlled by this view
- State that’s only controlled by a parent
- State that’s controlled by both this view and its parent
There’s also external state with environment variables and observables, but I’m going to ignore these in this post for brevity.
State controlled by this view
If you have some local state to keep track of, this is easy:
struct MyView: View {
@State private var toggleSelected = false
...
This state can now be changed by MyView
which will rerender whenever toggleSelected
is edited. This state can be passed down to child views, but a parent cannot edit this state.
State controlled by a parent
Often, you want a parent view to control a variable without letting the child view edit it. This can be done with a simple instance variable.
struct MyView: View {
let text: String
...
}struct ParentView: View {
@State private var text = "hello" var body: some View {
MyView(text: text)
}
}
At first glance, it may appear that text
is immutable since it’s declared with let
. However, whenever text
on the parent changes, it will call body
again and recreate a new instance of MyView
with a new text
value. This allows the parent to control the state of a child without allowing the child to edit this value.
State controlled by both the parent and child
And finally, sometimes you want state to be controlled by both the parent and the child. For this, you use a binding:
struct MyView: View {
@Binding var text: String
...
}struct ParentView: View {
@State private var text = "" var body: some View {
MyView(text: $text)
}
}
Now, both the parent and child can change the value of text and they share this value. If either the parent change the value of text
both views will rerender.
A need for something different
In my app, ZenDen, there’s a feature where you can reassign a task to someone else in your home. This view needs to keep track of which person is selected but it only actually changes if you tap save. So, it makes sense that only the child keeps track of this state. When you tap save, you can simply inform the parent view of this change through a completion block.
Here’s the code so far:
struct ReassignView: View {
// Called with the selected assignee and keep it fair on save
let saveTapped: (String, Bool) -> Void
@State var selectedAssignee = "Anyone"
...
}
But there’s a product problem here. Currently, the view will always initialize selectedAssignee
with Anyone
. But, I wanted to set an initial value which is the current assignee for the task. So, if the task is currently assigned to me, I want the reassign view to pop up and show Peter as the selected assignee. I want to initialize the child’s state with an initial value. You’d think this would be easy by simply passing it in the initializer:
struct ReassignView: View {
@State var selectedAssignee = "" init(assignee: String) {
selectedAssignee = assignee
}
}
Sadly, this just doesn’t work. No matter what you pass in as assignee
, selectedAssignee
will always be initialized to empty string. I can only speculate why this is the case, but it seems like @State
can only be mutated outside of the init
. Luckily, there’s a workaround:
struct ReassignView: View {
@State var selectedAssignee: String init(assignee: String) {
_selectedAssignee = State(initialValue: assignee)
}
}
_selectedAssignee
is a trick to access the wrapper object instead of the string. It has the type State<String>
and here, it’s initialized with the assignee. Now, when it’s first initialized, the parent can control the initial value. After that, the ReassignView
will control the value of selectedAssignee
.
But wait, there’s a bug?
Consider the following code:
// in the parent
var body: some View {
ReassignView(assignee: "Peter")
}
- The parent creates the
ReassignView
with the initial value “Peter” - The
ReassignView
changes its internal state to “Alice” - The parent is rerendered for some reason (e.g. some unrelated state has changed)
init
will be called onReassignView
and the assignee will be set back to “Peter”
Won’t this cause a bug?
Actually, no, it won’t. To understand why, we need to understand how SwiftUI
injects state into View
objects. Although SwiftUI
is closed source and the documentation is currently non-existent, it seems to follow this sequence of events:
- Call
init
on theView
- If there’s stored state for this view, set all the
@State
properties - Call
body
on theView
So, even though in init
, we set the initial value of selectedAssignee
, this is always overridden before body
is called if it’s previously been set. In this case, selectedAssignee
will indeed be initialized with the value “Peter”, but it will be changed to “Alice” before body
is called.
But wait, isn’t this an antipattern?
The main problem I have with this approach is that it’s not clear to the parent view that this is the behavior. The initializer looks identical to an initializer when the state is controlled by the parent. Consider these two side by side:
Text(myText)
ReassignView(assignee: myAssignee)
If myText
changes, then the text will rerender on screen. However, if myAssignee
changes, it will not update the ReassignView
. Since this new view behaves differently from the stock SwiftUI views, one might consider this an antipattern.
I’ve adopted the convention of prefixing all of these initializer arguments with initial
so the initializer becomes:
ReassignView(initialAssignee: myAssignee)
SwiftUI is still new and so it’s up to us (the community) to define conventions like this. Let me know in the comments what you think of this convention and if you have other ideas.
Other ways to accomplish this
Instead of this approach, you could simply make selectedAssignee
a binding. However, this feels like it leaks implementation details to the parent since it doesn’t need to ever read this value. It also now needs to worry about resetting this value at the correct time (whenever a new ReassignView
is presented). Ultimately, I think this makes it a worse approach.
You could also avoid setting the State
with a default value:
struct ReassignView: View {
let initialAssignee: String
@State private var storedAssignee: String? = nil
private var selectedAssignee: String {
get { storedAssignee ?? initialAssignee }
set { storedAssignee = newValue }
}
}
However, this still falls to the same antipattern problems as my approach and requires significantly more boilerplate.
Phew…that was crazy…
Welcome to SwiftUI! 😛 It’s fun.
We’ve just released our new app — ZenDen, and it’s written entirely with SwiftUI. If you have any questions about writing a production app in SwiftUI, let me know in the comments. I’m planning on writing a few more posts on SwiftUI, so let me know what to write next.
We wrote ZenDen to reduce the stress caused by organizing household tasks. The mental load of organizing a home often unfairly falls on one individual when most of this organization can be automated.
We’re always trying to improve, so let us know if you have any feedback on the app.
Thanks to Felix Lapalme for helping review this post.