Halt and Catch Fire: A SwiftUI @StateObject counterexample
Learn how to avoid some trivial SwiftUI state management pitfalls from a dead-simple counterexample of misusing @ObservedObject in SwiftUI
Some time ago, I encountered a strange situation, where the analytics events of the SwiftUI app I was working on stopped getting sent to Amplitude altogether. After investigating, I determined that events were getting sent. It's just that I had a backlog of hundreds of thousands, if not millions, of events waiting to be sent.
How did it come to that? I had screwed up my SwiftUI view's lifecycle, leading to my view being rebuilt over and over, tens of thousands of times per second. Automation can be like that sometimes: it scales both great ideas and crass mistakes. Which left us with what I thought was a nice counter-example.
Let's pause here for a minute and introduce or clarify a few things before we dive into the specifics.
Useful definitions and concepts
Counterexamples
What are counter-examples? Do they know things? Let's fin... Kidding aside, I hadn't really heard of counter-examples as a learning tool until recently. But I am now convinced we could hardly understate their usefulness.
In the words of jamestimmins (emphasis my own):
"Counterexamples" seems like a massively underexplored area for teaching good software design.
"How to design good software" is a huge question, but "what are some mistakes you've seen with tool X" seems like a great way to catalogue common errors that people make while learning.
I'd love for a major framework/tool to poll its users and create an index of common design counterexamples. e.g. "common design mistakes with serializers, models, caching, etc".
+1 if that resource included ways to refactor away from the bad design. So if the counterexample is "Querying the database directly from the controller functions", there could be a list of ways to refactor a codebase away from that design in pieces, and what to consider at each step (since some problems aren't worth the effort of fixing).
SwiftUI view lifecycle
A SwiftUI view's lifecycle is tied to its state and SwiftUI's state management system. A SwiftUI view being a struct
, its properties usually can't be changed. Changing the provided init parameters will simply recreate the view from scratch.
That's where the various SwiftUI property wrappers come in, to help us pass state and data between views, and more finely manage view rendering and re-rendering.
For more on that, I can only recommend John Sundell's great introductions to SwiftUI views lifecycle and SwiftUI state management.
Our counterexample
A Sad State of Mismanagement
First, we need a simple ObservableObject
class MySadlyMismanagedState: ObservableObject {
@Published var someCounter: Int
init(someCounter: Int) {
self.someCounter = someCounter
}
}
Next, we need a SwiftUI View
listening to that ObservableObject
's @Published
property and doing something stupid with it.
struct MySadlyMismanagedView: View {
@ObservedObject var sadlyMismanagedState: MySadlyMismanagedState
var body: some View {
VStack(spacing: 10) {
Label("Hello World!", systemImage: "globe.europe.africa.fill")
Text("Counter: \(sadlyMismanagedState.someCounter)")
}
.onReceive(sadlyMismanagedState.$someCounter) { counter in
print("Counter: \(counter)")
sadlyMismanagedState.someCounter += 1
}
}
}
Altogether, in a nice Playground:
import PlaygroundSupport
import SwiftUI
class MySadlyMismanagedState: ObservableObject {
@Published var someCounter: Int
init(someCounter: Int) {
self.someCounter = someCounter
}
}
struct MySadlyMismanagedView: View {
@ObservedObject var sadlyMismanagedState: MySadlyMismanagedState
var body: some View {
VStack(spacing: 10) {
Label("Hello World!", systemImage: "globe.europe.africa.fill")
Text("Counter: \(sadlyMismanagedState.someCounter)")
}
.onReceive(sadlyMismanagedState.$someCounter) { counter in
print("Counter: \(counter)")
sadlyMismanagedState.someCounter += 1
}
}
}
let state = MySadlyMismanagedState(someCounter: 0)
let view = MySadlyMismanagedView(sadlyMismanagedState: state)
PlaygroundPage.current.setLiveView(view)
Run it, sit back, and watch your phone turn into a ball of flame, melt a hole in your desk, and sink to the Earth's core.
Root cause
What's going on here? Why is our counter going crazy?
It's quite simple. Our view listens to someCounter
. When the value changes, our closure is called. This closure prints the current counter value, and then increments it. Which changes the value of someCounter
. Our view listens to someCounter
. When the value changes, our closure is called. This closure...
Yeah, obvious. The purpose of this counterexample is to simply show what could go wrong by not being careful when implementing some feedback loop between our SwiftUI views and our ViewModels / Controllers / whatever. The feedback loop can be simple, like here, or extend all the way to the backend and back.
Solution
In this case, the only real solution is to rethink what it is we are trying to do. It is unlikely we actually want to increment our counter whenever it is incremented.
Perhaps what we truly want is to increment that counter when our view appears? If so, here's how we'd do that.
import PlaygroundSupport
import SwiftUI
class MySadlyMismanagedState: ObservableObject {
@Published var someCounter: Int
init(someCounter: Int) {
self.someCounter = someCounter
}
}
struct MySadlyMismanagedView: View {
@ObservedObject var sadlyMismanagedState: MySadlyMismanagedState
var body: some View {
VStack(spacing: 10) {
Label("Hello World!", systemImage: "globe.europe.africa.fill")
Text("Counter: \(sadlyMismanagedState.someCounter)")
}
.onAppear() {
sadlyMismanagedState.someCounter += 1
}
.onReceive(sadlyMismanagedState.$someCounter) { counter in
print("Counter: \(counter)")
}
}
}
let state = MySadlyMismanagedState(someCounter: 0)
let view = MySadlyMismanagedView(sadlyMismanagedState: state)
PlaygroundPage.current.setLiveView(view)
Epilogue
Nothing groundbreaking here, but it seemed like a simple introduction to the concept of counterexamples. I hope it also was a valuable one to you. As always, thank you for reading, do reach out should you have any questions or suggestions, and have a great day!
Oh, and remember to let your iPhone / iPad / Macbook cool down after this one.
Comments ()