SwiftUI Data Flow

#code #swift

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:

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

iOS 14 / WWDC20

iOS 15 / WWDC21

iOS 16 / WWDC22

iOS 17 / WWDC23

iOS 18 / WWDC24

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

2. Read/Write Access

3. Global Access

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!