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.
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:
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+).
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:
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:
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:
@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?
- You created the model inside body.
let session = StudySession()inbodymakes a new object on every redraw. Create it once with@State. - You mutated a copy. Structs are values: passing one without a binding gives the child a copy.
- Your class isn't observable. Forgetting
@Observablemeans 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.
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.
Related posts
iOS
Swift Concurrency Explained: async/await, Tasks and Actors with Real Examples
A practical guide to modern Swift concurrency. Learn async/await, Task, TaskGroup, actors and @MainActor by building a small app feature step by step, plus the mistakes that cause bugs.
7 min read