@State for simple properties.@Binding.@Observable and @Bindable.@Environment to provide data app-wide.In SwiftUI, you declare what your UI should look like for a given state. The framework handles the how.
State is the data that determines how your app looks and behaves at any moment.
State management ensures:
Key Takeaway: SwiftUI is declarative. You describe the destination, not the step-by-step directions.
(This slide contains detailed notes for your review after the lecture.)
2.2 Declarative Syntax and Reactivity
SwiftUI uses a declarative syntax, which means you describe the UI's state at any point in time, and SwiftUI takes care of the rendering. Here's how it works:
- You define your UI as a function of its state, not through a sequence of events. For example, you are not responsible for writing code to handle a
Toggle's UI change; the toggle observes a state variable and represents its value automatically.- Changes in state trigger a re-rendering of only the UI components affected by those changes.
- This approach minimizes bugs and inconsistencies since the UI is a direct reflection of the state.
For instance, consider a simple counter app. In an imperative framework, you'd write code like:
button.onClick { count += 1; label.text = "\(count)" }. In declarative SwiftUI, you just change the state:count += 1, and theTextview automatically updates because it depends oncount.
SwiftUI gives us special tools called property wrappers to manage state. They tell SwiftUI that a property is a source of truth that the view depends on.
Modern Wrappers (iOS 17+):
@State: Manages simple, local data owned by a single view.@Binding: Creates a two-way connection to state owned by another view.@Observable: Marks a class as a complex, sharable model object.@Bindable: Creates bindings to the properties of an @Observable model.@Environment: Injects shared data deep into the view hierarchy.These wrappers are the building blocks of all state management in SwiftUI.
No! If a view only displays data that never changes, you can pass it in as a simple property. This is great for simple, reusable views.
struct WelcomeHeaderView: View {
// This view just displays data. It doesn't own or change it.
var username: String
var body: some View {
VStack {
Text("Welcome back, \(username)!")
.font(.largeTitle)
}
.padding()
}
}
@State is a viewβs private source of truth for simple, local properties (like Int, String, Bool).
struct DonutCounterView: View {
// This state is owned by, and private to, this View.
@State private var count = 0
var body: some View {
VStack {
Text("Orders: \(count)")
HStack {
Button("-") { if count > 0 { count -= 1 } }
.buttonStyle(.borderedProminent)
Button("+") { count += 1 }
.buttonStyle(.borderedProminent)
}
}
.padding()
}
}
Explanation: By marking count with @State, we tell SwiftUI: "If this value changes, redraw this view."
In layman's terms: If you add the @State property, SwiftUI will observe this value. If it's changing, the UI gets redrawn. When the value also needs to be changed by a UI control (like a Toggle), you must use the $ prefix to create a two-way connection, called a Binding.
@State WorksKey Takeaway:
@Stategives a view its own private "source of truth" and automatically redraws the view when that value changes.
@Stateproperties are mutable even though they are declared in astruct, which is typically immutable. SwiftUI manages this by storing the state outside the view's structure in a persistent, managed memory location, allowing it to change without violating the immutability of the view itself.
- Reactivity: Whenever the state property's value changes, SwiftUI reinvokes the view's
bodyproperty to generate a new UI.- Data Binding: The
$prefix creates aBinding. This is a reference-like connection that allows other views to read and write the value.Sidenote for Geeks: The
$syntax is shorthand for the property wrapper'sprojectedValue._count.projectedValuewould produce the sameBinding.
Start with the Example DonutCounterView and extend it.
Build a counter with +/β buttons clamped to 0β¦maxValue and a Reset button that resets the counter to 0. The reset button should be disabled if the count is already zero.
The maxValue is defined as a variable (so it can be configured) and defaults to 10.
Note: You can either hardcode, use a range (check the dock how to use a
range) or a variable for the upper limit. Note: You don't need a state for this. Check the very first example.
Optional: Show a βMax reachedβ label when count == maxValue.
struct LimitedCounterView: View {
@State private var count = 0
var maxValue = 10
var body: some View {
VStack {
// BONUS
if count == maxValue {
Text("Max reached!").foregroundColor(.orange)
}
Text("Orders: \(count)").font(.headline)
HStack {
Button("-") { if count > 0 { count -= 1 } }
.buttonStyle(.borderedProminent)
.disabled(count == 0)
Button("+") { if count < maxValue { count += 1 } }
.buttonStyle(.borderedProminent)
.disabled(count == maxValue)
}
Button("Reset") { count = 0 }
.buttonStyle(.bordered)
.tint(.red)
.disabled(count == 0)
}
.padding()
}
}
A @Binding creates a two-way connection to a state owned by another view. The child view can read and write the data, but the parent remains the single source of truth.
// Parent owns the data with @State
struct DonutOrderView: View {
@State private var donutName = "Chocolate Glaze"
var body: some View {
VStack(spacing: 12) {
// Child gets a binding with '$'
CustomDonutOrder(donutName: $donutName)
Text("Preparing: \(donutName)")
}
}
}
// Child uses @Binding to get access to the parent's state
struct CustomDonutOrder: View {
@Binding var donutName: String
var body: some View {
// The TextField can now directly edit the parent's state
TextField("Donut name", text: $donutName)
.textFieldStyle(.roundedBorder)
}
}
This is essential for creating reusable components.
Your goal is to create a reusable DonutStepperView.
Requirements:
DonutStepperView that takes a @Binding to an Int.+ and β buttons and accept a range to know when to disable them.DonutOrderView from the previous slide to hold a @State variable for quantity.DonutStepperView inside DonutOrderView to control the quantity.// The reusable child view for changing a value
struct DonutStepperView: View {
@Binding var quantity: Int
let range: ClosedRange<Int>
var body: some View {
HStack {
Button("-") { if quantity > range.lowerBound { quantity -= 1 } }
.buttonStyle(.borderedProminent).disabled(quantity == range.lowerBound)
Text("\(quantity)").padding(.horizontal).bold()
Button("+") { if quantity < range.upperBound { quantity += 1 } }
.buttonStyle(.borderedProminent).disabled(quantity == range.upperBound)
}
}
}
// The reusable text field view from the previous example
struct CustomDonutOrder: View {
@Binding var donutName: String
var body: some View {
TextField("Donut name", text: $donutName)
.textFieldStyle(.roundedBorder)
}
}
// The parent view now uses both reusable components
struct DonutOrderView: View {
@State private var donutName = "Chocolate Glaze"
@State private var quantity = 1
var body: some View {
VStack(spacing: 12) {
CustomDonutOrder(donutName: $donutName)
DonutStepperView(quantity: $quantity, range: 1...12)
Divider()
Text("Preparing: \(quantity)x \(donutName)")
}
.padding()
}
}
@ObservableFor complex, shared data used across many screens (like an array of orders), we use a model object. With the modern Observation framework, you just add @Observable to your class.
import Observation
// Define your data structure
struct Donut: Identifiable {
let id = UUID()
var name: String
}
// Create an @Observable class to manage the data
@Observable
class FoodTruckModel {
var donuts = [Donut(name: "Glazed"), Donut(name: "Chocolate")]
func addDonut() {
let options = ["Strawberry Sprinkle", "Boston Cream", "Maple Bar"]
donuts.append(Donut(name: options.randomElement()!))
}
}
How it works: @Observable automatically tracks which views read which properties and updates them efficiently when the data changes.
@BindableHow do you create a Binding to a property inside an @Observable model (e.g., for a TextField)? The @Bindable property wrapper gives you binding access.
struct DonutEditorView: View {
// This view takes an instance of the model
@State var foodTruckModel = FoodTruckModel()
var body: some View {
// Use @Bindable to get binding access.
@Bindable var model = foodTruckModel
Form {
// Now you can use '$' to bind to the model's properties.
TextField("First Donut Name", text: $model.donuts[0].name)
}
.navigationTitle("Edit Donut")
}
}
@EnvironmentTo avoid passing a model manually through many views, place it in the Environment. This makes it available to any child view that needs it.
Why use Environment?
First, let's see a simple parent-child example where a parent view provides a model to its children:
// A parent view creates and provides the model
struct DonutMenuView: View {
@State private var foodTruckModel = FoodTruckModel()
var body: some View {
VStack {
// Provide the model to all child views
DonutListView()
Divider()
DonutStatsView()
}
.environment(foodTruckModel) // All children can now access this
}
}
// First child: displays the list of donuts
struct DonutListView: View {
@Environment(FoodTruckModel.self) private var model
var body: some View {
List(model.donuts) { donut in
Text(donut.name)
}
}
}
// Second child: displays statistics
struct DonutStatsView: View {
@Environment(FoodTruckModel.self) private var model
var body: some View {
Text("Total donuts: \(model.donuts.count)")
.font(.headline)
}
}
Key Point: Both DonutListView and DonutStatsView access the same model from the environment without it being passed explicitly.
For data that needs to be available everywhere, provide it at the app level:
// In your App's main entry point
@main
struct DonutShopApp: App {
// 1. Create the single source of truth for the app.
@State private var foodTruckModel = FoodTruckModel()
var body: some Scene {
WindowGroup {
ContentView()
// 2. Place the model into the environment.
.environment(foodTruckModel)
}
}
}
// Any view, anywhere in the app, can now access it
struct OrderListView: View {
@Environment(FoodTruckModel.self) private var model
var body: some View {
// If the model is not found, the app will crash. This is by design.
List(model.donuts) { donut in
Text(donut.name)
}
}
}
// Even deeply nested views have access
struct OrderDetailView: View {
@Environment(FoodTruckModel.self) private var model
let donutId: UUID
var body: some View {
if let donut = model.donuts.first(where: { $0.id == donutId }) {
VStack {
Text(donut.name).font(.title)
Button("Add Another") {
model.addDonut()
}
}
}
}
}
Start here: What kind of data are you managing?
β
ββ Simple value (Int, String, Bool) owned by THIS view?
β ββ Use @State
β
ββ Need to modify a parent's @State from a child view?
β ββ Use @Binding (passed from parent with $)
β
ββ Complex object (class) with multiple properties?
β ββ Is it shared across multiple views?
β β ββ YES β Use @Observable class
β β β ββ Access it with @Environment
β β β
β β ββ NO β Use @Observable class with @State
β β
β ββ Need a Binding to properties inside an @Observable object?
β ββ Use @Bindable (for TextField, Toggle, etc.)
β
ββ Receiving data from another view?
ββ Just displaying it (read-only)?
β ββ Use a regular property (no wrapper)
β
ββ Need to modify it?
ββ Use @Binding
Quick Reference:
@State = I own this simple data@Binding = I can read AND write someone else's data@Observable = Complex class that multiple views watch@Bindable = Create bindings to @Observable properties@Environment = Access shared data from parent/appKey Takeaway: Most state issues come from having more than one "source of truth" or using the wrong tool for the job.
(This slide contains detailed notes for your review after the lecture.)
Ignoring the Single Source of Truth: Don't keep copies of the same state in multiple places. Have one owner (
@Stateor@Statewith an@Observablemodel) and pass bindings (@Bindingor@Bindable) to other views that need to make edits.Using
@Statefor Complex/Shared Data:@Stateis for simple, view-local properties. For data that needs to be shared across views or has complex logic, use an@Observableclass.Retain Cycles in Models: In your
@Observableclasses, be careful with closures that captureself. A closure can create a strong reference to the class instance, and if the class instance holds a strong reference back to the closure (e.g., via aTimer), neither can be deallocated. Use[weak self]in the closure's capture list to prevent this memory leak.Missing Environment Object: If you use
@Environment, you must provide the object with.environment()from a parent view. Forgetting to do this is a common reason for runtime crashes.
Key Takeaway: You will see this older pattern in existing projects. Recognize it, but use
@Observablefor all new code.(This slide contains detailed notes for your review after the lecture.)
Before the Observation framework, managing observable objects required more boilerplate code.
The Old Way:
- Conform a class to the
ObservableObjectprotocol.- Mark each property you want to track with
@Published.- The view that creates the object uses
@StateObject.- Views that receive the object use
@ObservedObject.- For the environment, you use
.environmentObject(...)and@EnvironmentObject.// LEGACY PATTERN - DO NOT USE FOR NEW CODE class OldFoodTruckModel: ObservableObject { @Published var orders: [Donut] = [] } struct OldDonutView: View { // @StateObject for creation and ownership @StateObject private var model = OldFoodTruckModel() var body: some View { List(model.orders) { order in Text(order.name) } } }The modern
@Observablemacro replaces all of this, is more performant, and is much simpler to use.
State management is the most important skill in your journey as a SwiftUI developer.
Key Takeaways:
@State for simple, local view data.@Observable: It is the primary tool for all shared, complex state.@Binding and @Bindable to create two-way connections for UI controls.@Environment is for dependency injection to avoid manual pass-through.In this example, weβll use @State to manage the state of various form components within a single view. This illustrates how to handle local state effectively in a form setting.
struct RegistrationForm: View {
@State private var username = ""
@State private var email = ""
@State private var password = ""
@State private var confirmPassword = ""
var body: some View {
Form {
Section(header: Text("Account Information")) {
TextField("Username", text: $username)
TextField("Email", text: $email)
SecureField("Password", text: $password)
SecureField("Confirm Password", text: $confirmPassword)
}
Section {
Button("Register") {
registerAccount()
}
.disabled(!isFormValid())
}
}
.navigationTitle("Sign Up")
}
private func isFormValid() -> Bool {
return !username.isEmpty && !email.isEmpty && password == confirmPassword && password.count >= 8
}
private func registerAccount() {
// Handle the registration logic
print("Registering account...")
}
}
Explanation:
RegistrationForm uses @State for each input field to track their contents independently.@State is ideal for local data that does not need to be shared outside the view.This example demonstrates using @Binding to link a child component's state back to a parent view, facilitating two-way data communication.
struct ParentView: View {
@State private var lightIsOn = false
var body: some View {
VStack {
Toggle("Enable Light", isOn: $lightIsOn)
ChildView(isLightOn: $lightIsOn)
}
}
}
struct ChildView: View {
@Binding var isLightOn: Bool
var body: some View {
Text(isLightOn ? "The light is ON" : "The light is OFF")
.foregroundColor(isLightOn ? .yellow : .gray)
.padding()
.background(isLightOn ? .black : .white)
.cornerRadius(10)
}
}
Explanation:
ParentView manages the state of a light switch using @State.ChildView receives a @Binding to this state, allowing it to display and react to changes in the light's status. Changes in ParentView reflect immediately in ChildView, demonstrating the power of data binding in SwiftUI.In the realm of state management in SwiftUI, a few common pitfalls can trip up even experienced developers. Understanding these pitfalls and knowing how to avoid them can significantly enhance the stability and performance of SwiftUI applications.
@State for complex data structures or data shared across multiple views, which can lead to bugs and unexpected behaviors.@State only for simple local data unique to a view. For complex or shared data structures, employ @StateObject, @ObservedObject, or @EnvironmentObject which are designed to handle data observed by multiple views.@Binding to cases where two-way data flow is genuinely needed. Ensure that the data flow remains clear and logical. Simplify the UI logic to prevent over-binding.@StateObject for owning and managing the lifecycle of observable objects and pass data to other views using @Binding or views that observe the object with @ObservedObject.Incorrect Approach
In this incorrect example, we have two separate views that each manage their own state for a shared data concept (userData), leading to potential inconsistencies.
struct UserProfileView: View {
@State private var userData = UserData(name: "Alice", age: 28)
var body: some View {
VStack {
Text("Name: \(userData.name)")
Text("Age: \(userData.age)")
EditButtonView(userData: $userData)
}
}
}
struct EditButtonView: View {
var userData: UserData // Not a binding or state - just a copy!
var body: some View {
Button("Update Age") {
// This creates a local copy and modifies it,
// but the change is lost and doesn't affect the parent
var mutableCopy = userData
mutableCopy.age += 1
}
}
}
struct UserData {
var name: String
var age: Int
}
Problem: The EditButtonView receives userData as a regular parameter, not a binding. Even though the parent passes $userData, without @Binding in the child, it just receives a copy. Changes made to this copy don't affect the parent's data.
Correct Approach
A better approach is to have a single source of truth, with UserProfileView owning the userData and passing it to EditButtonView through a binding.
struct UserProfileView: View {
@State private var userData = UserData(name: "Alice", age: 28)
var body: some View {
VStack {
Text("Name: \(userData.name)")
Text("Age: \(userData.age)")
EditButtonView(userData: $userData)
}
}
}
struct EditButtonView: View {
@Binding var userData: UserData
var body: some View {
Button("Update Age") {
userData.age += 1
}
}
}
struct UserData {
var name: String
var age: Int
}
Solution: In this corrected example, UserProfileView manages the state of userData using @State, and EditButtonView receives this data as a @Binding. Now, any changes made in EditButtonView are directly reflected in UserProfileView, maintaining consistency across the user interface.
@Binding effectively allows for the proper propagation of changes in state from child views back to the parent, which maintains the ownership and integrity of the data.Incorrect Approach: Strong Capture of self
In this example, a SwiftUI view uses a timer that periodically updates an observable object. The timerβs closure captures self strongly, which can lead to a retain cycle if not handled correctly.
import SwiftUI
import Combine
class TimerManager: ObservableObject {
@Published var counter = 0
var timer: Timer?
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.counter += 1 // Strong capture of `self` can lead to a retain cycle
}
}
deinit {
print("TimerManager is being deinitialized")
}
}
struct TimerView: View {
@StateObject private var timerManager = TimerManager()
var body: some View {
VStack {
Text("Counter: \(timerManager.counter)")
Button("Start Timer") {
timerManager.startTimer()
}
}
}
}
Problem: The timer closure captures self (an instance of TimerManager) strongly. Since TimerManager owns the timer, and the timer's closure captures TimerManager strongly, neither will be deallocated even if TimerView is removed from the view hierarchy.
Correct Approach: Weak Capture of self
The correct approach involves modifying the closure to capture self weakly to prevent the retain cycle.
import SwiftUI
import Combine
class TimerManager: ObservableObject {
@Published var counter = 0
var timer: Timer?
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.counter += 1 // Weak capture of `self` to avoid retain cycles
}
}
deinit {
timer?.invalidate()
print("TimerManager is being deinitialized")
}
}
struct TimerView: View {
@StateObject private var timerManager = TimerManager()
var body: some View {
VStack {
Text("Counter: \(timerManager.counter)")
Button("Start Timer") {
timerManager.startTimer()
}
}
}
}
Solution: By using [weak self] in the closure, self is captured weakly, preventing a retain cycle. Now, when TimerView is removed, TimerManager and its timer can be properly deallocated, assuming no other strong references are held.
[weak self]: When closures in SwiftUI or any Swift code might capture self strongly, it's safer to capture self weakly using [weak self] to avoid retain cycles (german: zyklische Referenzen, bzw. zyklische AbhΓ€ngigkeiten).deinit method in your classes can be a helpful way to debug and ensure that objects are being deallocated as expected.@EnvironmentObject has it injected in its environment by a parent view or at the root of the application.Incorrect Approach: Not Injecting @EnvironmentObject
Here's an example where a SwiftUI view expects an EnvironmentObject, but it is not properly provided by any parent views, leading to a runtime error.
import SwiftUI
class UserData: ObservableObject {
@Published var name: String = "Alice"
}
struct ProfileView: View {
@EnvironmentObject var userData: UserData // Expected to be provided by the environment
var body: some View {
Text("User name: \(userData.name)")
}
}
struct ContentView: View {
var body: some View {
ProfileView() // This will crash because UserData is not provided
}
}
Problem: The ProfileView expects UserData to be available as an EnvironmentObject, but ContentView doesn't inject it. Running this code results in a crash when ProfileView tries to access userData.
Correct Approach: Properly Providing @EnvironmentObject
To correct this issue, ensure that UserData is provided by a parent view or at the application's entry point.
import SwiftUI
class UserData: ObservableObject {
@Published var name: String = "Alice"
}
struct ProfileView: View {
@EnvironmentObject var userData: UserData // Provided by the environment
var body: some View {
Text("User name: \(userData.name)")
}
}
struct ContentView: View {
@StateObject var userData = UserData() // Create and own UserData
var body: some View {
ProfileView()
.environmentObject(userData) // Inject UserData into the environment
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Solution: In this corrected example, ContentView creates an instance of UserData and injects it into the environment. Any descendant views like ProfileView can now safely access userData as an EnvironmentObject without causing a crash.
@EnvironmentObject used within a view is provided by a parent or the application itself.@EnvironmentObject, check to make sure it is properly injected and available in the view hierarchy.Throughout this chapter on state management in SwiftUI, we have covered a range of concepts crucial for developing dynamic and responsive applications. From the foundational @State and @Binding property wrappers to more advanced tools like @StateObject, @ObservedObject, and @EnvironmentObject, we've explored how each plays a unique role in managing application state.
@State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject) based on the scope and lifecycle of the data is key to effective state management.@State for complex data, overusing bindings, ignoring the single source of truth principle, inadvertently creating memory leaks, and mismanaging environment objects. Awareness and avoidance of these pitfalls are crucial for building robust applications.With the new @Observable Macro the implementation of ObservedObjects is now much easier. This feature was introduced with Swift 5.9
Example
@Observable class FoodTruckModel {
var orders: [Order] = []
var donuts = Donut.all
}
struct DonutMenu: View {
let model: FoodTruckModel
var body: some View {
List {
Section("Donuts") {
ForEach(model.donuts) { donut in
Text(donut.name)
}
Button("Add new donut") {
model.addDonut()
}
}
}
}
}
Instead of conforming the class FoodTruckModel to the Observable protocol, all you need to do is to add the @Observerable Macro. The rest is done magically. No need to use the @Published property wrappers.
Additionally the @EnvironmentObject property wrapper is replaced with @Environment, making the code even shorter.
The @Bindable property wrapper introduced in Swift 5.9 allows you to create bindings to mutable properties of objects that conform to the Observable protocol. This addition is significant as it simplifies the creation of reactive user interfaces in SwiftUI by providing a straightforward way to link UI components directly to the underlying data model's properties.
@Observable
class Book: Identifiable {
var title = "Sample Book Title"
var isAvailable = true
}
struct BookEditView: View {
@Bindable var book: Book
@Environment(\.dismiss) private var dismiss
var body: some View {
Form {
TextField("Title", text: $book.title)
Toggle("Book is available", isOn: $book.isAvailable)
Button("Close") {
dismiss()
}
}
}
}
This example shows how to create a form to edit the properties of a Book instance. The @Bindable wrapper provides direct bindings to the title and isAvailable properties, facilitating immediate updates through UI components like TextField and Toggle.
struct LibraryView: View {
@State private var books = [Book(), Book(), Book()]
var body: some View {
List(books) { book in
@Bindable var book = book
TextField("Title", text: $book.title)
}
}
}
In this scenario, @Bindable is used within a list to bind each TextField to the title property of a book directly, allowing for inline editing within a list context.
struct TitleEditView: View {
@Environment(Book.self) private var book
var body: some View {
@Bindable var book = book
TextField("Title", text: $book.title)
}
}
This example illustrates using @Bindable to bind to a property of an observable object stored in a viewβs environment, enabling direct modifications to the title through a TextField.
The @Bindable property wrapper is a powerful addition to SwiftUI's toolkit, streamlining the way developers handle dynamic data binding in their applications. By using @Bindable, you can create more maintainable and responsive applications with less code and greater clarity.
State management is an area of constant evolution and learning. As SwiftUI continues to mature and evolve, staying updated with the latest best practices and features is essential. Encourage experimentation with these concepts in different scenarios to build a deep understanding and intuition for managing state effectively.