Skip to content
AgileLogics
7 min read

SwiftUI State and Data Flow

Prerequisites: SwiftUI fundamentals, Macros

In SwiftUI, the interface is a function of state: change the data, and the views that depend on it update automatically. The core skill is deciding where each piece of state lives.

@State — state owned by one view

import SwiftUI

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Text("Count: \(count)")
                .font(.title)
            Button("Add one") {
                count += 1
            }
        }
    }
}

@State stores the value outside the view struct, so it survives when SwiftUI re-creates the view. Changing count re-renders the Text. Mark @State properties private — they belong to this view only.

@Binding — let a child view change a parent's state

struct VolumeSlider: View {
    @Binding var volume: Double

    var body: some View {
        Slider(value: $volume, in: 0...10)
    }
}

struct PlayerView: View {
    @State private var volume = 5.0

    var body: some View {
        VStack {
            Text("Volume: \(Int(volume))")
            VolumeSlider(volume: $volume)
        }
    }
}

$volume passes a binding — a two-way connection to the parent's state, not a copy. There's still one source of truth: PlayerView owns the value; VolumeSlider reads and writes it.

@Observable — shared model objects

For data shared across several views, or with logic attached, use a class marked with the @Observable macro (iOS 17 / macOS 14 and later):

import SwiftUI

@Observable
final class CartModel {
    var items: [String] = []
    var total: Int { items.count * 5 }

    func add(_ item: String) {
        items.append(item)
    }
}

struct ShopView: View {
    @State private var cart = CartModel()

    var body: some View {
        VStack {
            Text("Total: $\(cart.total)")
            Button("Add coffee") { cart.add("coffee") }
            CartBadge(cart: cart)
        }
    }
}

struct CartBadge: View {
    let cart: CartModel

    var body: some View {
        Text("\(cart.items.count) items")
    }
}
  • The view that creates the model owns it with @State.
  • Views that just use it take it as a plain property — SwiftUI tracks which properties each view reads and updates only those views.
  • To pass a model deep into the view tree without threading it through every initializer, use .environment(cart) on a parent and @Environment(CartModel.self) private var cart in the child.

(Projects supporting older OS versions use the earlier ObservableObject protocol with @Published, @StateObject, and @ObservedObject. You'll see these in existing code.)

Choosing

Situation Use
Simple value used by one view @State
Child view needs to change a parent's value @Binding
Model with logic, or shared between views @Observable class
Model needed deep in the hierarchy .environment(...)

Common mistakes

  • Creating a model inside body (let cart = CartModel()), which recreates it — and loses its data — on every update. Store it in @State.
  • Duplicating state: copying a value into a child's own @State, then wondering why the two drift apart. Pass a binding instead.