Move real state out of views into an observable model the whole screen shares, run async work with the task modifier, pass data down the tree with the environment, and add animations.
For state beyond a single view — data loaded from a network, shared across a screen — you use a reference-type model marked @Observable (or ObservableObject on older OS versions). A view holds it with @State and reads its properties; when the model changes, the views that read it re-render. This is the seam between your UI and the app’s real state and logic.
import SwiftUI
@Observable // makes the class's properties observable
final class CounterModel {
var count = 0
func increment() { count += 1 }
}
struct CounterScreen: View {
@State private var model = CounterModel() // the view owns the model
var body: some View {
Button("Count: \(model.count)") { // reading re-subscribes
model.increment() // change -> re-render
}
}
}
// @Observable holds real state + logic outside the view. (Pre-iOS 17: use
// ObservableObject + @Published + @StateObject.) See the Architecture course.The task modifier runs an async job when a view appears and cancels it automatically when the view disappears — the safe way to load data for a screen. Give it a value to re-run when that value changes (like re-fetching for a new id). It ties async work to the view’s lifetime, mirroring the structured-concurrency idea from the Swift course.
struct UserProfile: View {
let userId: Int
@State private var name = "Loading..."
var body: some View {
Text(name)
.task(id: userId) { // runs on appear; re-runs if userId changes
name = await fetchName(userId) // cancelled automatically on disappear
}
}
}
// 'task' = load-on-appear + auto-cancel, tied to the view's lifetime.
// re-fetches when 'id' changes. No leaked network work.Passing a shared model through many layers of view properties is tedious, so SwiftUI’s environment lets you inject a value at a high level and read it anywhere below with @Environment — no manual threading through every view. It is ideal for app-wide things like a theme, a user session, or a shared model, and keeps intermediate views clean.
@Observable final class Session { var username = "Ada" }
struct RootView: View {
@State private var session = Session()
var body: some View {
HomeView()
.environment(session) // inject once, high in the tree
}
}
struct DeepChildView: View {
@Environment(Session.self) private var session // read anywhere below
var body: some View {
Text("Hi, \(session.username)")
}
}
// inject at the top, read deep — no passing the model through every view.SwiftUI animates changes almost for free: wrap a state change in withAnimation, or attach an animation modifier, and SwiftUI interpolates between the old and new UI. Because the UI is a function of state, animating means telling SwiftUI how to transition when that state changes. This makes fluid motion easy without manual frame math.
struct ExpandCard: View {
@State private var expanded = false
var body: some View {
VStack {
Text("Tap to expand")
if expanded { Text("More details here...") }
}
.frame(height: expanded ? 200 : 80)
.onTapGesture {
withAnimation(.spring) { // animate the state change
expanded.toggle()
}
}
}
}
// wrap a state change in withAnimation and SwiftUI interpolates the UI between
// states. Animating = describing how to transition when state changes.