SwiftUI Data Flow
SwiftUI is over 5 years old. While a lot has changed since its introduction at WWDC19, the fundamentalsβ especially with respect to data flowβremain pretty much the same. Sure, there's some new Observation framework syntax to learn, but Apple has done a great job at "simplifying" and "unifying" APIs while maintaining backwards compatibility.
This post is meant to serve as a reference guide for modern (iOS 17/18+) SwiftUI data flow. It ought to be particularly useful for working with LLMs that might crank out outdated code.
Data Flow
By "data flow" I mean: how data moves through an app and updates the UI. Data flow in SwiftUI is actually quite simple... as long as you keep two principles in mind: views automatically update when their data changes, and every piece of data needs a single source of truth.
Since its release Apple has produced hundreds of hours of WWDC videos on SwiftUI. If you just watch these three videos you'll be like 90% up to speed:
- Data Flow Through SwiftUI (WWDC19)
- Data Essentials in SwiftUI (WWDC20)
- Discover Observation in SwiftUI (WWDC23)
Value vs Reference
I don't want to get too in the weeds, but in order to see and understand how data flow has changed we have to differentiate between value types (π¦) and reference types (π).
Value types like Strings and Ints and Doubles and Bools and structs and enums, create independent copies when assigned. In contrast, reference types, like classes, share the same instance.
Apple strongly recommends using value types for most cases. If we followed this advice more often I wouldn't have even need to write this post as most of the data flow changes in SwiftUI (from iOS 13/14 to iOS 17/18+) are related to reference types!
Timeline
Legend: β
= current, β
= avoid, π¦
= value, π
= reference
iOS 13 / WWDC19
β
π¦
@State
β
π¦
@Binding
β
π
ObservableObject
replaced with@Observable
in iOS 17+β
π
@Published
redundant when using@Observable
in iOS 17+β
π
@ObservedObject
replaced with@Bindable
in iOS 17+β
π
@EnvironmentObject
replaced with@Environment
in iOS 17+β
π¦
@Environment
β
π¦
EnvironmentKey
replaced with@Entry
in iOS 18+β
PreviewProvider
replaced with#Preview
in iOS 17+
iOS 14 / WWDC20
β
π
@StateObject
replaced with@State
in iOS 17+
iOS 15 / WWDC21
- No significant data flow changes
iOS 16 / WWDC22
- No significant data flow changes
iOS 17 / WWDC23
β
π
@Observable
replacesObservableObject
β
π
@State
replaces@StateObject
β
π
@Bindable
β replaces@ObservedObject
β
π
@Environment
replaces@EnvironmentObject
β
#Preview
replacesPreviewProvider
iOS 18 / WWDC24
β
π¦
@Entry
replacesEnvironmentKey
Rosetta Code
Here's a simple, but practical, example of modern data flow in SwiftUI (I'd encourage you to copy-and-paste this block of code into Xcode to experiment with the syntax for yourself):
// iOS 17/18+
import SwiftUI
extension EnvironmentValues {
// β
π¦ replaces `EnvironmentKey`
@Entry var lastRefresh: Date = .now
}
// β
π replaces `ObservableObject`
@Observable
class UserStore {
var username = "max" // β
π replaces @Published
var hasPremium = false // β
π replaces @Published
func randomize() {
username = String((0..<3).map { _ in "abcdefghijklmnopqrstuvwxyz".randomElement()! })
}
}
// β
π replaces `ObservableObject`
@Observable
class EmojiStore {
var emoji = "π" // β
π replaces @Published
func update() {
emoji = String(UnicodeScalar(Int.random(in: 0x1F600...0x1F64F))!)
}
}
struct ParentView: View {
@State var lastRefresh: Date = .now // β
π¦ unchanged
@State var userStore = UserStore() // β
π replaces `StateObject`
@State var emojiStore = EmojiStore() // β
π replaces `StateObject`
@State var count: Int = 0 // β
π¦ unchanged
var body: some View {
VStack(spacing: 15) {
HStack {
Text("Last Refresh: \(lastRefresh.formatted(date: .omitted, time: .complete))")
Button("Refresh") { lastRefresh = .now }
}
HStack {
Text("Username: \(userStore.username)")
Button("Randomize") { userStore.randomize() }
}
HStack {
Text("Has premium: \(userStore.hasPremium ? "Yes" : "No!")")
Button("Toggle") { userStore.hasPremium.toggle() }
}
HStack {
Text("Emoji: \(emojiStore.emoji)")
Button("Update") { emojiStore.update() }
}
HStack {
Text("Count: \(count)")
Button("+1") { count += 1 }
Button("-1") { count -= 1 }
}
ChildView(
emojiStore: emojiStore, // β
π unchanged
count: $count // β
π¦ unchanged
)
}
.padding()
.background(Rectangle().stroke(.purple, lineWidth: 1))
.environment(\.lastRefresh, lastRefresh) // β
π¦ unchanged
.environment(userStore) // β
π replaces `.environmentObject`
}
}
struct ChildView: View {
@Environment(\.lastRefresh) private var lastRefresh: Date // β
π¦ unchanged
@Environment(UserStore.self) private var userStore: UserStore // β
π replaces `@EnvironmentObject`
@Bindable var emojiStore: EmojiStore // β
π replaces `@ObservedObject`
@Binding var count: Int // β
π¦ unchanged
var body: some View {
VStack(spacing: 15) {
HStack {
Text("Last Refresh: \(lastRefresh.formatted(date: .omitted, time: .complete))")
}
HStack {
Text("Edit username:")
@Bindable var userStore = userStore // β οΈ π FRUSTRATING!
TextField("", text: $userStore.username)
.textInputAutocapitalization(.never)
.fixedSize()
}
HStack {
Text("Has premium: \(userStore.hasPremium ? "Yes" : "No!")")
Button("Toggle") { userStore.hasPremium.toggle() }
}
HStack {
Text("Emoji: \(emojiStore.emoji)")
Button("Update") { emojiStore.update() }
}
HStack {
Text("Count: \(count)")
Button("+1") { count += 1 }
Button("-1") { count -= 1 }
}
}
.padding()
.background(Rectangle().stroke(.mint, lineWidth: 1))
}
}
// β
replaces `PreviewProvider`
#Preview("Parent") {
ParentView()
}
// β
replaces `PreviewProvider`
#Preview("Child") {
@Previewable @State var userStore = UserStore() // β
π replaces `StateObject`
@Previewable @State var emojiStore = EmojiStore() // β
π replaces `StateObject`
@Previewable @State var count = 10 // β
π¦ unchanged
ChildView(emojiStore: emojiStore, count: $count)
.environment(\.lastRefresh, .now) // β
π¦ unchanged
.environment(userStore) // β
π replaces `.environmentObject`
}
And here's the same example as above, just painted with the old, outdated syntax:
// iOS 13/14/15/16
import SwiftUI
// βοΈ π¦ replaced with `@Entry`
private struct LastRefreshKey: EnvironmentKey {
static let defaultValue: Date = .now
}
extension EnvironmentValues {
// βοΈ π¦ replaced with `@Entry`
var lastRefresh: Date {
get { self[LastRefreshKey.self] }
set { self[LastRefreshKey.self] = newValue }
}
}
// βοΈ π replaced by `@Observable`
class UserStore: ObservableObject {
@Published var username = "max" // βοΈ π redundant when using `@Observable`
@Published var hasPremium = false // βοΈ π redundant when using `@Observable`
func randomize() {
username = String((0..<3).map { _ in "abcdefghijklmnopqrstuvwxyz".randomElement()! })
}
}
// βοΈ π replaced by `@Observable`
class EmojiStore: ObservableObject {
@Published var emoji = "π" // βοΈ π redundant when using `@Observable`
func update() {
emoji = String(UnicodeScalar(Int.random(in: 0x1F600...0x1F64F))!)
}
}
struct ParentView: View {
@State private var lastRefresh: Date = .now // β
π¦ unchanged
@StateObject private var userStore = UserStore() // βοΈ π replaced with `@State`
@StateObject private var emojiStore = EmojiStore() // βοΈ π replaced with `@State`
@State private var count: Int = 0 // β
π¦ unchanged
var body: some View {
VStack(spacing: 15) {
HStack {
Text("Last Refresh: \(lastRefresh.formatted(date: .omitted, time: .complete))")
Button("Refresh") { lastRefresh = .now }
}
HStack {
Text("Username: \(userStore.username)")
Button("Randomize") { userStore.randomize() }
}
HStack {
Text("Has premium: \(userStore.hasPremium ? "Yes" : "No!")")
Button("Toggle") { userStore.hasPremium.toggle() }
}
HStack {
Text("Emoji: \(emojiStore.emoji)")
Button("Update") { emojiStore.update() }
}
HStack {
Text("Count: \(count)")
Button("+1") { count += 1 }
Button("-1") { count -= 1 }
}
ChildView(
emojiStore: emojiStore, // β
π¦ unchanged
count: $count // β
π¦ unchanged
)
}
.padding()
.background(Rectangle().stroke(Color.purple, lineWidth: 1))
.environment(\.lastRefresh, lastRefresh) // β
π¦ unchanged
.environmentObject(userStore) // βοΈ π replaced with `.environment`
}
}
struct ChildView: View {
@Environment(\.lastRefresh) private var lastRefresh: Date // β
π¦ unchanged
@EnvironmentObject private var userStore: UserStore // βοΈ π replaced with `@Environment`
@ObservedObject var emojiStore: EmojiStore // βοΈ π replaced with `@Bindable`
@Binding var count: Int // β
π¦ unchanged
var body: some View {
VStack(spacing: 15) {
HStack {
Text("Last Refresh: \(lastRefresh.formatted(date: .omitted, time: .complete))")
}
HStack {
Text("Edit username:")
TextField("", text: $userStore.username)
.textInputAutocapitalization(.never)
.fixedSize()
}
HStack {
Text("Has premium: \(userStore.hasPremium ? "Yes" : "No!")")
Button("Toggle") { userStore.hasPremium.toggle() }
}
HStack {
Text("Emoji: \(emojiStore.emoji)")
Button("Update") { emojiStore.update() }
}
HStack {
Text("Count: \(count)")
Button("+1") { count += 1 }
Button("-1") { count -= 1 }
}
}
.padding()
.background(Rectangle().stroke(Color.mint, lineWidth: 1))
}
}
// βοΈ replaced with `#Preview`
struct ParentView_Previews: PreviewProvider {
static var previews: some View {
ParentView()
}
}
// βοΈ replaced with `#Preview`
struct ChildView_Previews: PreviewProvider {
static var previews: some View {
Preview()
}
struct Preview: View {
@StateObject var userStore = UserStore() // βοΈ π replaced with `@State`
@StateObject var emojiStore = EmojiStore() // βοΈ π replaced with `@State`
@State var count: Int = 10 // β
π¦ unchanged
var body: some View {
ChildView(emojiStore: emojiStore, count: $count)
.environment(\.lastRefresh, .now) // β
π¦ unchanged
.environmentObject(userStore) // βοΈ π replaced with `.environment`
}
}
}
Between the Lines
As you can see, the two blocks of code are largely the same. The only real differences are in the reference type data flow. Thanks to Observation and @Observable
things are a little more simple and a little better aligned with how value types have been handled since iOS 13:
1. Source of Truth
- π¦
@State
- π
@State
replacing@StateObject
2. Read/Write Access
- π¦
@Binding
- π
@Bindable
replacing@ObservedObject
3. Global Access
- π¦
@Environment
- π
@Environment
replacing@EnvironmentObject
Rough Edges
Observation framework is a great improvement to data flow in SwiftUI, but there are still some awkward parts. The most notable is that we need two different two-way read-write binding mechanisms (@Binding
and @Bindable
) instead of one unified approach. Even more frustrating is the explicit "rebinding" requirement for working with environment objects:
@Environment(UserStore.self) private var userStore
// ...
@Bindable var userStore = userStore // Required "rebinding"
Hopefully Apple unifies these binding mechanisms in the future, similar to how they've deftly consolidated State and Environment handling!