“For every action, there is a reaction”—Newton’s Third Law of Motion.
This timeless principle, grounded in physics, beautifully mirrors the essence of state management in software. In SwiftUI, everything is driven by state: every user interaction, every update, every transition, causes a reaction to the state we define.
State forms the bedrock of even the simplest UI. Imagine a basic function that accepts input. Before its logic unfolds, the state of the input may determine the behavior of the function. Whether the input is valid or nil, our code will react in some way.
This may seem like a very meta-level take, but in SwiftUI, this principle scales exponentially. Our views are always in flux, reacting to state changes, with every interaction pushing the UI toward its next desired state.
What is the Minimal State?
This brings us to a key guiding principle for state management in SwiftUI: “What is the minimal state needed to keep the UI accurate and responsive?” By considering this question, we aim to simplify our approach, ensuring that we aren’t bogged down by unnecessary complexity or performance issues.
By identifying the minimal set of states, we can avoid bloated views and improve performance, resulting in a more responsive and maintainable application.
A Review: Tools of the Trade
Before we begin, let’s examine some of the vehicles for state in SwiftUI.
@State: Local state, private to the view. This can hold an immeadiate, basic object (String, Int, etc.) or, with the advent of Observation, an Observable.
@Binding: A reference to state owned by a parent.
@StateObject & @ObservedObject: A reference to an external ObservableObject (Combine) with data updates.
@Environment/@EnvironmentObject: A globally injected Observable/ObservableObject (respectfully), often for app- or experience-wide state.
Note: What I won’t do in this article is get into architectural patterns (MV vs MVVM vs Etc). Regardless of the pattern you choose, there are core concepts about state management and SwiftUI that, ultimately, a pattern needs to consider. That will be our focus here.
Building-out State
Let’s pretend we spin up a brand new project in Xcode. ContentView starts with:
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
This is ground-zero: an app with a single, static state.
If we added a state property and set it to the View, as is, we’d still only have a state of one:
struct ContentView: View {
@State var statefulText: String = "Stateful Text"
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text(statefulText)
}
.padding()
}
}
If we introduce a way for user-input to be captured and cause a reaction, we could, at minimum, expand to a 2 state paradigm:
struct ContentView: View {
@State var statefulText: String = "Stateful Text"
var body: some View {
VStack {
Button {
self.statefulText = "Ouch!"
} label: {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
}
Text(statefulText)
}
.padding()
}
}
Ok, I think you get where I’m going with this: States don’t just appear, we declare their possibility.
With that in mind, let’s raise the bar on complexity.
More Complexity
Let’s add a bit more complexity now to our app:
enum OurStates {
case loading
case loaded
case error
case whoami
}
struct ContentView: View {
@State var currentState: OurStates = .loading
@State var statefulText: String = "We did it!"
var body: some View {
Group {
switch currentState {
case .loading:
ContentUnavailableView("One moment please...",
systemImage: "hourglass")
case .loaded:
VStack {
Button {
self.statefulText = "Ouch!"
} label: {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
}
Button {
self.currentState = .whoami
} label: {
Image(systemName: "x.circle")
.imageScale(.large)
.foregroundStyle(.tint)
}
Text(statefulText)
}
.padding()
case .error:
ContentUnavailableView("Oops!",
systemImage: "x.circle")
case .whoami:
Text("Existential Crisis!")
}
}
.task {
do {
try await Task.sleep(nanoseconds: 2000000000)
self.currentState = .loaded
} catch {
self.currentState = .error
}
}
}
}
We’ve introduced an enum that actually defines states, create a State property to track an instance of OurState, and use it in a switch statement in our View. Based on the current value of OurState, our UI could become completely different. Plus, we’ve introduced another Button that alters OurState.
When you attempt to map it, it already starts to become complex:
The experienced developers reading this article are laughing right now, mainly because they’ve dealt with way more complex state diagrams. But the point here is that all of this resides in just 1 SwiftUI View. And this is just between 2 State properties.
As we continue to declare sub-views, reactive properties, and sub-states, the complexity could grow exponentially.
Multi-State Harmony
Most applications cannot avoid multi-state experiences, especially if they expect to offer any points of user interaction. It’s important, then, that states co-exist in harmony, especially with the reactive ones.
If we take our guiding principle from earlier (“What is the minimal state needed to keep the UI accurate and responsive?”), we get a clue as to how to achieve better management.
Here are the keys:
What are the inter-dependencies between states?
Can they be mapped to a tree?
Can our UI be broken up according to this tree?
Following the Branches of our State Tree
We already saw how branches of states exist from the earlier example where our state enum broke our UI into 4 main paths (loading, loaded, error, and whomai). At the parent level, currentState is the minimal state needed to understand what should be displayed on our UI.
Looking at this, however, we also expose that statefulText is really only the concern of the loaded state. Yes, the currentState can be changed in the loading state, as well, but if we could still reference that property, could we separate out statefulText from where it’s unnecessary?
enum OurStates {
case loading
case loaded
case error
case whoami
}
struct ContentView: View {
@State var currentState: OurStates = .loading
var body: some View {
Group {
switch currentState {
case .loading:
ContentUnavailableView("One moment please...",
systemImage: "hourglass")
case .loaded:
LoadedView(currentState: $currentState)
case .error:
ContentUnavailableView("Oops!",
systemImage: "x.circle")
case .whoami:
Text("Existential Crisis!")
}
}
.task {
do {
try await Task.sleep(nanoseconds: 2000000000)
self.currentState = .loaded
} catch {
self.currentState = .error
}
}
}
}
struct LoadedView: View {
@Binding var currentState: OurStates
@State var statefulText: String = "We did it!"
var body: some View {
VStack {
Button {
self.statefulText = "Ouch!"
} label: {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
}
Button {
self.currentState = .whoami
} label: {
Image(systemName: "x.circle")
.imageScale(.large)
.foregroundStyle(.tint)
}
Text(statefulText)
}
.padding()
}
}
You’ll notice that there’s now a LoadedView which binds to the current state, but now takes over ownership of statefulText. This not only centralizes the focus of the loaded sub-states, but it also frees up our parent view tremendously in terms of readability and responsibility.
Refining our Guiding Question
Our guiding question is still valid, but it is a recursive one. As we examine our states and identify the branches of sub-states (based on inter-dependencies), we’re presented an opportunity to (literally) branch off. We can still be connected (through bindings and declarations within the hierarchy, itself), but the sub-states can now be kept within smaller, more manageable chunks.
In this case, we chose to do that through sub-views. But maybe it makes sense for your code to do this through Observables or SwiftData objects. And as you continue to break things up, you may realize an entire section would be better served as an independent chunk, altogether (perhaps as a Swift Package for reuse elsewhere, because it’s no longer tied to a specific app).
There aren’t necessarily wrong approaches, but the concept for managing states, especially complex sets of states, is to figure out how we can reduce them down or break them up.
Think of a company/organization. By setting up departments and teams within those departments, it helps to make the greater picture more manageable. This is achievable even as the organization grows (which we expect our apps to do as new features could be added).
By doing so, we hit the goal of our guiding question because within each grouping, we can focus on that “minimal state”, and increase success of keeping our UI “accurate and responsive”.
State = .conclusion
As with the other topics in the Craftsmanship series, mastering these topics by adopting their guiding questions will help us achieve expert-level SwiftUI experiences. When it comes to State Management, the complexity can grow quite a bit and even more so with actual live/reactive data sources. But by breaking states up into more mangeable chunks and ensuring we only keep the minimal states actually needed, we can have better insight to the overall state of our app/experience.
One potential issue with moving the @State var to the LoadingView is that it is moving state to an optional view, which could lead to the state resetting as it leaves the view hierarchy and comes back.