- Explain the difference between
@Stateand@Bindingand find the single source of truth - Create a model class with the
@Observablemacro and own it with@State - Share a model between views with
@Environmentand@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.
struct CounterView: View {
var count = 0
var body: some View {
Button("Taps: \(count)") {
count += 1
}
}
}struct CounterView: View {
@State private var count = 0
var body: some View {
Button("Taps: \(count)") {
count += 1
}
}
}@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.
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")
}
}
}+/− buttons and a text below it; tapping a button changes the number in both rows together.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:
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)")Score: 4 / 5, finished: true
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)
}
}final class QuizModel: ObservableObject {
@Published var score = 0
}
struct QuizView: View {
@StateObject private var quiz = QuizModel()
// child views: @ObservedObject, @EnvironmentObject
}@Observable
final class QuizModel {
var score = 0
}
struct QuizView: View {
@State private var quiz = QuizModel()
// child views: plain property, @Bindable, @Environment
}@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.
@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)!")
}
}@State private var profile = Profile() and calls .environment(profile). When you change the name in the field, the “Hello, …!” text updates at once.| Tool | When |
|---|---|
@State | the view owns a value or an @Observable model |
@Binding | a child reads and changes its parent's value |
@Bindable | you need bindings to an @Observable model's properties |
@Environment | read a system value or a model placed in the environment |
a plain let | the 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.
- 1Create a model file
Press
Cmd+N, chooseSwift Filein theiOSsection, name itQuizModeland pressCreate. Write the@Observableclass there. - 2Create a view file
Press
Cmd+Nagain, this time with theSwiftUI Viewtemplate; name itQuizView. The template adds a#Previewblock too. - 3Test 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.
Key points
- Every piece of data should have one owner — a single source of truth.
@State private varis a view's own state; when it changes,bodyis recalculated.@Bindingis a two-way link to a parent's value and is passed with$.- An
@Observableclass (iOS 17+) tracks the properties that are read; own the model with@Stateand share it with@Environmentand@Bindable. - MVVM: the Model provides data, an
@ObservableViewModel manages state, and the View displays it.
Check yourself
10 questions. Every correct answer earns XP.