Skip to content
Sign in

SwiftUI Data Flow: @State, @Binding, @Observable and @Environment Explained

Learn which SwiftUI property wrapper to use and why, by building a small study-timer app. Clear rules, diagrams in words, and the mistakes that make views not update.

CodeOrbit Learn TeamPublished 4 min read

SwiftUI views are descriptions of the screen, recreated whenever data changes. The question is always: who owns this piece of data, and who only reads or edits it? Answer that, and the right property wrapper follows.

We will build a tiny study timer: pick a subject, set minutes, start and stop.

@State: the view owns simple data

Use @State for small values that belong to one view.

Swift
struct TimerView: View {
    @State private var minutes = 25
    @State private var isRunning = false

    var body: some View {
        VStack(spacing: 16) {
            Text("\(minutes) min").font(.largeTitle.bold())
            Stepper("Minutes", value: $minutes, in: 5...90, step: 5)
            Button(isRunning ? "Stop" : "Start") { isRunning.toggle() }
                .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

@Binding: let a child edit the parent's state

The $minutes you passed to Stepper is a binding: a two-way connection to data owned elsewhere. Your own subviews can accept one too:

Swift
struct SubjectPicker: View {
    @Binding var subject: String
    let subjects = ["Maths", "Science", "Computer", "English"]

    var body: some View {
        Picker("Subject", selection: $subject) {
            ForEach(subjects, id: \.self) { Text($0) }
        }
        .pickerStyle(.segmented)
    }
}

// Parent:
@State private var subject = "Maths"
SubjectPicker(subject: $subject)

The child doesn't own subject; it just edits the parent's copy.

@Observable: shared models with logic

When data has behaviour (timers, network calls) or several screens use it, move it into a class marked with the @Observable macro (iOS 17+).

Swift
import Observation

@Observable
final class StudySession {
    var subject = "Maths"
    var secondsLeft = 25 * 60
    var completedSessions = 0
    private var timer: Timer?

    var isRunning: Bool { timer != nil }

    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            guard let self else { return }
            secondsLeft -= 1
            if secondsLeft == 0 { finish() }
        }
    }

    func stop() { timer?.invalidate(); timer = nil }

    private func finish() {
        stop()
        completedSessions += 1
        secondsLeft = 25 * 60
    }
}

SwiftUI tracks which properties a view actually reads. A view showing only completedSessions won't redraw every second when secondsLeft changes, a nice performance win over the older ObservableObject.

Create the model once, where it should live, and hold it with @State:

Swift
struct StudyScreen: View {
    @State private var session = StudySession()

    var body: some View {
        VStack {
            Text(timeText(session.secondsLeft)).font(.system(size: 56, weight: .bold, design: .rounded))
            Button(session.isRunning ? "Pause" : "Start") {
                session.isRunning ? session.stop() : session.start()
            }
            SessionCount(session: session)
        }
    }
}

struct SessionCount: View {
    let session: StudySession          // a plain reference is enough for reading
    var body: some View { Text("Sessions today: \(session.completedSessions)") }
}

Need a binding into an @Observable model? Use @Bindable:

Swift
struct SubjectEditor: View {
    @Bindable var session: StudySession
    var body: some View { TextField("Subject", text: $session.subject) }
}

@Environment: data many views need

Put app-wide objects in the environment instead of passing them through every initialiser:

Swift
@main
struct StudyApp: App {
    @State private var session = StudySession()
    var body: some Scene {
        WindowGroup { StudyScreen().environment(session) }
    }
}

struct DeepChildView: View {
    @Environment(StudySession.self) private var session
    var body: some View { Text(session.subject) }
}

Which one do I use?

Situation

Use

Simple value owned by this view

@State

Child edits a value the parent owns

@Binding

Model class with logic, created by this view

@State with an @Observable class

Child needs bindings into that model

@Bindable

Many views across the app need it

.environment + @Environment

Why isn't my view updating?

  1. You created the model inside body. let session = StudySession() in body makes a new object on every redraw. Create it once with @State.
  2. You mutated a copy. Structs are values: passing one without a binding gives the child a copy.
  3. Your class isn't observable. Forgetting @Observable means SwiftUI can't see changes.

Practice task

Add a daily goal (for example 4 sessions) and show a progress ring that fills as completedSessions grows. Put the goal in the environment so a Settings screen can change it.

Tags:#SwiftUI#Swift#iOS

Frequently asked questions

What replaced ObservableObject and @Published?

On iOS 17 and later, the @Observable macro replaces them with less code and more precise updates. ObservableObject still works and you will see it in older projects.

Can I use @State with a struct?

Yes. @State works with any value type. Use a struct for simple grouped data and an @Observable class when you need shared identity or behaviour.

Is @EnvironmentObject still needed?

For @Observable models, use .environment(model) and @Environment(Model.self). @EnvironmentObject is the older equivalent for ObservableObject.