Skip to content
Educora
Advanced22 min11 / 14

State and data flow

Keep state in SwiftUI with `@State`, pass it to child views with `@Binding`, create a model with the `@Observable` macro, share it through `@Environment` and structure the app in the MVVM style.

Check yourself
In this lesson you will learn
  • Explain the difference between @State and @Binding and find the single source of truth
  • Create a model class with the @Observable macro and own it with @State
  • Share a model between views with @Environment and @Bindable
  • Describe the MVVM layers in SwiftUI

Nigar raises her daily goal from 15 to 30 minutes on the settings screen, but the main screen still says “15 min”. The reason is simple: the same value was stored in two places that know nothing about each other. The golden rule of SwiftUI is that every piece of data must have one owner — a single source of truth — and the other views should read it or bind to it. In this lesson you will learn the tools for this.

@State: a view's own state

A View struct is immutable, and SwiftUI may recreate it at any moment. That is why a changing value cannot live in an ordinary property. @State asks SwiftUI to keep the value outside the struct and to recalculate body when it changes. Always declare a @State property as private: it belongs to this view only. The initial value is used only once, when the view first appears; after that SwiftUI uses the value it has stored.

Wrong: a plain property
struct CounterView: View {
    var count = 0

    var body: some View {
        Button("Taps: \(count)") {
            count += 1
        }
    }
}
Right: @State
struct CounterView: View {
    @State private var count = 0

    var body: some View {
        Button("Taps: \(count)") {
            count += 1
        }
    }
}
The code on the left does not compile: “Cannot assign to property: 'self' is immutable”. On the right every tap increases the counter and updates the screen.

@Binding: a link to someone else's state

If a child view needs to change its parent's value, it gets not a copy but a binding — a two-way link. The parent passes the value with $ ($goal), and the child declares it as @Binding. When the child changes the value, it is really the parent's @State that changes, and both parts of the screen update at the same time.

Swift
struct GoalStepper: View {
    @Binding var minutes: Int

    var body: some View {
        Stepper("Daily goal: \(minutes) min", value: $minutes, in: 5...60, step: 5)
    }
}

struct SettingsScreen: View {
    @State private var goal = 15

    var body: some View {
        Form {
            GoalStepper(minutes: $goal)
            Text("You will study \(goal) minutes a day")
        }
    }
}
On screen: a row with +/− buttons and a text below it; tapping a button changes the number in both rows together.
Definition
Single source of truth

The one place where a piece of data is stored. In the example it is @State private var goal in SettingsScreen; GoalStepper only binds to it and keeps no copy of its own.

@Observable models

When a screen's data and logic grow, we move them into a separate model class. Since iOS 17 it is enough to write the @Observable macro in front of the class: SwiftUI itself tracks the properties that body reads and updates the view only when they change. You can check the model's logic without any screen, with plain Swift code:

Swift
import Observation

@Observable
final class QuizModel {
    var question = 1
    var score = 0
    var finished: Bool { question > 5 }

    func answer(correct: Bool) {
        if correct { score += 1 }
        question += 1
    }
}

let quiz = QuizModel()
for correct in [true, false, true, true, true] {
    quiz.answer(correct: correct)
}
print("Score: \(quiz.score) / 5, finished: \(quiz.finished)")
Expected output
Score: 4 / 5, finished: true
Swift
struct QuizView: View {
    @State private var quiz = QuizModel()

    var body: some View {
        VStack(spacing: 16) {
            if quiz.finished {
                Text("Your score: \(quiz.score) / 5")
            } else {
                Text("Question \(quiz.question) of 5")
                Button("Correct") { quiz.answer(correct: true) }
                Button("Wrong") { quiz.answer(correct: false) }
            }
        }
        .font(.title2)
    }
}
On screen: “Question 1 of 5” and two buttons; after the fifth answer, a result such as “Your score: 4 / 5”.
Old: ObservableObject (iOS 13+)
final class QuizModel: ObservableObject {
    @Published var score = 0
}

struct QuizView: View {
    @StateObject private var quiz = QuizModel()
    // child views: @ObservedObject, @EnvironmentObject
}
New: @Observable (iOS 17+)
@Observable
final class QuizModel {
    var score = 0
}

struct QuizView: View {
    @State private var quiz = QuizModel()
    // child views: plain property, @Bindable, @Environment
}
In the old way every property needed @Published, and any change to the object updated every view that observed it. @Observable needs less code and reacts only to the properties that are actually read.

@Environment and @Bindable

Passing a model five levels down through a chain of parameters is tiring. Instead we put it into the environment with .environment(profile), and the view that needs it takes it with @Environment(Profile.self) private var profile. If you need a binding to a model property (for example, for a TextField), use @Bindable. @Environment also provides system values: \.dismiss, \.colorScheme, \.locale.

Swift
@Observable
final class Profile {
    var name = "Nigar"
}

struct ProfileEditor: View {
    @Bindable var profile: Profile

    var body: some View {
        TextField("Name", text: $profile.name)
    }
}

struct GreetingView: View {
    @Environment(Profile.self) private var profile

    var body: some View {
        Text("Hello, \(profile.name)!")
    }
}
The root view keeps @State private var profile = Profile() and calls .environment(profile). When you change the name in the field, the “Hello, …!” text updates at once.
ToolWhen
@Statethe view owns a value or an @Observable model
@Bindinga child reads and changes its parent's value
@Bindableyou need bindings to an @Observable model's properties
@Environmentread a system value or a model placed in the environment
a plain letthe view only displays the value

Together this forms the MVVM style: the Model — data structs and services (network, database); the ViewModel — an @Observable class that holds the screen's state and logic (QuizModel); the View — a SwiftUI struct that shows the state and passes the user's actions to the ViewModel's methods. Because the UI must only be updated on the main thread, view models are usually marked @MainActor.

  1. 1
    Create a model file

    Press Cmd+N, choose Swift File in the iOS section, name it QuizModel and press Create. Write the @Observable class there.

  2. 2
    Create a view file

    Press Cmd+N again, this time with the SwiftUI View template; name it QuizView. The template adds a #Preview block too.

  3. 3
    Test on the canvas

    Tap the buttons in the preview and check that the question number and, at the end, the result change — no need to launch the simulator.

create a new fileCmd+N
jump to the declaration of a type or function — Jump to DefinitionCtrl+Cmd+J
file structure: the list of properties and methodsCtrl+6
reveal the open file in the NavigatorCmd+Shift+J

Key points

  • Every piece of data should have one owner — a single source of truth.
  • @State private var is a view's own state; when it changes, body is recalculated.
  • @Binding is a two-way link to a parent's value and is passed with $.
  • An @Observable class (iOS 17+) tracks the properties that are read; own the model with @State and share it with @Environment and @Bindable.
  • MVVM: the Model provides data, an @Observable ViewModel manages state, and the View displays it.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
How do you declare a simple changing value that belongs to the view itself?