All you never wanted to know about state in SwiftUI

Peter Livesey
Dec 9, 2019 · 6 min read

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:

  1. State that’s only controlled by this view
  2. State that’s only controlled by a parent
  3. 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.

Users can reassign a task to someone else. If you select ‘Keep it fair’, it will automatically reassign future tasks to keep the load even.

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")
}
  1. The parent creates the ReassignView with the initial value “Peter”
  2. The ReassignView changes its internal state to “Alice”
  3. The parent is rerendered for some reason (e.g. some unrelated state has changed)
  4. init will be called on ReassignView 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:

  1. Call init on the View
  2. If there’s stored state for this view, set all the @State properties
  3. Call body on the View

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.

Find some Zen with ZenDen

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.

Device Blogs

Thoughts on building products, engineering, design and development

Thanks to Alice Avery

Peter Livesey

Written by

Coder, traveler and nerd. I also eat and drink.

Device Blogs

Thoughts on building products, engineering, design and development

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade