Skip to content
Commitwise
Menu
iOS Development

State and Data Flow

Decide where each piece of SwiftUI state lives, with @State, @Binding, @Observable, @Bindable and @Environment, and avoid the bugs that reset or freeze your screens.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

You tap a button. The value changes, you can see it in the debugger, and the screen… doesn’t update. Or it updates, but resets every time you scroll back. Almost every SwiftUI bug like this comes down to one question: who owns this piece of state?

New to some of these? Open what you need, or skip ahead.

Basics:How do SwiftUI views work?

In SwiftUI, a screen is built from views: small structs that conform to View and describe what to show in a body property:

struct Greeting: View {
    let name: String
    var body: some View {
        Text("Hello, \(name)!")
    }
}

You don’t change views after they’re shown. When the data they depend on changes, SwiftUI calls body again and updates the screen to match. Views are combined by nesting (VStack, List) and adjusted with modifiers like .padding() and .font(.title).

Basics:What are @State and the other @ attributes?

Words like @State, @Binding and @Observable are Swift attributes; most of the ones in SwiftUI are property wrappers. A property wrapper adds behavior to a property: @State var count = 0 still looks like a normal variable, but SwiftUI stores its value outside the view and redraws the view when it changes.

The $ prefix ($count) gives a binding: a two-way connection that lets another view, like a Toggle or TextField, read and change the value.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data.
Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:Mutable or immutable?

A mutable object can be changed after it’s created (list.add(x), user.name = "Ana"). An immutable one can’t: to “change” it, you create a new one with the new value.

Immutable data is easier to reason about: nobody can change it behind your back, and it’s safe to share between parts of the program (and between threads). Kotlin’s val properties and List, Java’s record and List.of, and Swift’s let and structs are the usual tools.

Part Not started

Views are a function of state

Think of a stadium scoreboard. Nobody repaints the numbers by hand when a team scores. Someone updates the score, and the board shows the new score automatically.

SwiftUI works the same way. You don’t change labels or buttons directly. You change the state (the data), and SwiftUI calls your view’s body again to draw the new version. Your job is to put each piece of state in the right place and mark it with the right property wrapper, so SwiftUI knows to watch it.

A SwiftUI View is a cheap value that describes UI; SwiftUI recreates these structs constantly. The storage behind @State doesn’t live in the struct: it lives in SwiftUI’s view graph and is tied to the view’s identity (its position in the tree, or an explicit .id). With the Observation framework (iOS 17+), SwiftUI records which properties each body reads and invalidates only the views that read a property that changed.

Nearly every state bug is one of two things: state stored somewhere that doesn’t survive a struct recreation, or identity changing when you didn’t expect it to.

@State: state a view owns

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        Button("Tapped \(count) times") {
            count += 1
        }
    }
}

@State tells SwiftUI: “keep this value for me, and redraw the view when it changes”. Mark it private: it belongs to this view, and nobody outside should set it.

@Binding: let a child change the parent’s state

struct SettingsView: View {
    @State private var notificationsOn = true

    var body: some View {
        Form {
            NotificationToggle(isOn: $notificationsOn)
            Text(notificationsOn ? "You'll get alerts" : "Alerts are off")
        }
    }
}

struct NotificationToggle: View {
    @Binding var isOn: Bool

    var body: some View {
        Toggle("Notifications", isOn: $isOn)
    }
}

The $ in $notificationsOn creates a binding: a two-way connection to the parent’s value. The child can read and write it, but the parent still owns it. There’s still only one copy of the truth, which is what makes the Text below update when you flip the toggle.

Predict the output

The user taps Rename. What does the screen show?

struct ParentView: View {
    @State private var name = "Ana"

    var body: some View {
        VStack {
            GreetingView(name: name)
            Button("Rename") { name = "Marko" }
        }
    }
}

struct GreetingView: View {
    @State var name: String

    var body: some View {
        Text("Hi, \(name)")
    }
}

Don’t store what you can compute

A common beginner habit is to give every number on screen its own @State. Then the values must be kept in sync by hand, and sooner or later they aren’t:

// Fragile: total is a second copy that you must remember to update
struct CartSummaryFragile: View {
    @State private var prices: [Double] = [4.5, 2.0]
    @State private var total = 6.5

    var body: some View {
        VStack {
            Text("Total: \(total, format: .currency(code: "EUR"))")
            Button("Add coffee") {
                prices.append(3.0)
                // forgot: total += 3.0
            }
        }
    }
}

// Simple: total is computed from prices every time body runs
struct CartSummary: View {
    @State private var prices: [Double] = [4.5, 2.0]

    private var total: Double { prices.reduce(0, +) }

    var body: some View {
        VStack {
            Text("Total: \(total, format: .currency(code: "EUR"))")
            Button("Add coffee") { prices.append(3.0) }
        }
    }
}

Ask of every value: “can I calculate this from other state?” If yes, make it a computed property. Only the things the user actually changes (here, the list of prices) need to be state. Computing a total on every redraw is cheap; a screen that shows the wrong total is not.

Predict the output

The user taps + three times, then turns Show counter off and on again. What does the counter show?

struct DetailsScreen: View {
    @State private var showCounter = true

    var body: some View {
        Form {
            Toggle("Show counter", isOn: $showCounter)
            if showCounter {
                CounterView()
            }
        }
    }
}

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        Stepper("Count: \(count)", value: $count)
    }
}

Identity decides lifetime

@State lives exactly as long as the view’s identity:

  • Structural identity: the position in the view tree. The two branches of an if/else are different views, so switching branches resets their state. Prefer modifiers (.opacity, .disabled) over branches when state must survive.
  • Explicit identity: .id(value) and ForEach ids. Changing the id destroys and recreates the view, which is a handy way to reset a form, and a nasty bug when ids aren’t stable (for example id: \.self on non-unique values, or a new UUID() on every load).
  • AnyView erases the type and makes diffing harder; prefer @ViewBuilder and some View.
  • @State storage lives in the view graph, tied to identity: if/else branches and a changed .id reset it, and later initial values from a parent are ignored.
  • A child that edits takes a @Binding; one that only displays takes a let. Derive values you can compute instead of storing them.

Check this part

  1. A child view needs to switch a setting that its parent owns. What should the child use?

  2. A screen shows a list of prices and their total. Which of them should be state?

  3. Why does @State var name: String in a child ignore new values from its parent?

  4. A form view gets .id(selectedUser.id). The user picks another user. What happens to the form's @State?

Views are a function of state: change the data and SwiftUI redraws. @State is storage a view owns, tied to its identity; @Binding lets a child edit a value its parent owns, so there's one copy of the truth.

Part Not started

@Observable: shared model objects

When several screens need the same data, like a shopping cart, put it in a class marked @Observable (iOS 17 and later).

import Observation

@Observable
final class CartModel {
    var items: [Product] = []
    var total: Double { items.reduce(0) { $0 + $1.price } }

    func add(_ product: Product) {
        items.append(product)
    }
}

struct ShopView: View {
    @State private var cart = CartModel()   // this view creates and owns the model

    var body: some View {
        NavigationStack {
            ProductList(cart: cart)
                .toolbar { CartBadge(cart: cart) }
        }
    }
}

struct CartBadge: View {
    let cart: CartModel                     // only reads it: no property wrapper needed

    var body: some View {
        Text("\(cart.items.count) items")
    }
}

struct DeliveryForm: View {
    @Bindable var order: OrderModel          // needs $bindings into the model

    var body: some View {
        TextField("Address", text: $order.address)
    }
}

Three rules cover most apps:

  1. The view that creates the model keeps it with @State, so it survives redraws.
  2. Views that only read it take it as a normal let property. SwiftUI still notices which properties they read.
  3. Views that need $ bindings (for a TextField or Toggle) take it as @Bindable.

For data that almost every screen needs, like the signed-in user, put it in the environment once with .environment(session) and read it anywhere with @Environment(SessionModel.self) private var session.

Spot the bugA profile that forgets

Tap the line with the bug.

struct ProfileScreen: View {
    var model = ProfileModel()

    var body: some View {
        VStack {
            Text(model.name)
            Button("Load") {
                Task { await model.load() }
            }
        }
    }
}

Editing items in a list

Bindings also work for each row of a list. ForEach($todos) gives every row a binding to its own item:

struct Todo: Identifiable {
    let id = UUID()
    var title: String
    var isDone = false
}

struct TodoListView: View {
    @State private var todos = [Todo(title: "Buy milk"), Todo(title: "Call mom")]

    var body: some View {
        List {
            ForEach($todos) { $todo in
                Toggle(todo.title, isOn: $todo.isDone)
            }
        }
    }
}

Identifiable gives every item a stable id, so SwiftUI knows which row is which when items are added, removed or reordered.

Which wrapper do I need?

SituationUse
A simple value only this view uses@State private var
A child needs to change the parent’s value@Binding in the child, $value in the parent
A shared model object this view creates@Observable class + @State private var
A shared model this view only readslet model: Model
A shared model this view edits through $@Bindable var model: Model
Data the whole app needs.environment(model) + @Environment(Model.self)

Practice: rows that edit the list

Here’s the checklist from earlier, split into a reusable row view. Tick a todo and look at the “done” label:

Spot the bugA done counter that never moves

Tap the line with the bug.

struct TodoRow: View {
    @State var todo: Todo

    var body: some View {
        Toggle(todo.title, isOn: $todo.isDone)
    }
}

struct TodoListView: View {
    @State private var todos = [Todo(title: "Buy milk"), Todo(title: "Call mom")]

    var body: some View {
        List {
            ForEach(todos) { todo in
                TodoRow(todo: todo)
            }
            Text("\(todos.filter { $0.isDone }.count) done")
        }
    }
}

Exerciseeasy

Build a tip calculator

Build a screen where the user types the bill amount, picks a tip of 10%, 15% or 20%, and sees the tip and the total update as they type.

  • A TextField for the bill (use value: with format: .number, so you get a Double, not text)
  • A segmented Picker for the tip percentage
  • Two Text rows: the tip and the total

Before you write code, decide: which values are state, and which are computed?

›

Only two things change because the user did something: the bill and the chosen percentage. Those two are @State. The tip and the total are computed from them.

›

A segmented picker is Picker("Tip", selection: $tipPercent) { … }.pickerStyle(.segmented), with one Text("15%").tag(15) per option. The tag’s type must match the type of tipPercent.

SolutionTry it yourself first
struct TipCalculatorView: View {
    @State private var bill = 0.0
    @State private var tipPercent = 15

    private let percentages = [10, 15, 20]
    private var tip: Double { bill * Double(tipPercent) / 100 }
    private var total: Double { bill + tip }

    var body: some View {
        Form {
            Section("Bill") {
                TextField("Amount", value: $bill, format: .number)
                    .keyboardType(.decimalPad)
            }
            Section("Tip") {
                Picker("Tip", selection: $tipPercent) {
                    ForEach(percentages, id: \.self) { percent in
                        Text("\(percent)%").tag(percent)
                    }
                }
                .pickerStyle(.segmented)
            }
            Section {
                LabeledContent("Tip", value: tip, format: .currency(code: "EUR"))
                LabeledContent("Total", value: total, format: .currency(code: "EUR"))
            }
        }
    }
}

Two @State values, two computed properties, and no code that “updates the total”: SwiftUI recomputes body whenever bill or tipPercent changes, and the computed values come along for free.

Want more? Add a “Split between” stepper for 1 to 10 people and show the amount per person. It’s one more @State and one more computed property.

Observation vs ObservableObject

With ObservableObject, any @Published change fires objectWillChange and every view holding the object re-evaluates body. With @Observable, SwiftUI tracks property reads per body, so a badge reading items.count doesn’t redraw when an unrelated address changes.

ObservableObject (iOS 13+)Observation (iOS 17+)
class M: ObservableObject + @Published@Observable class M
@StateObject var m = M()@State var m = M()
@ObservedObject var m: Mlet m: M or @Bindable var m: M
@EnvironmentObject var m: M@Environment(M.self) var m

Gotchas in production

  • @State with a class runs the initializer every time. @StateObject took an autoclosure; @State doesn’t, so @State private var model = Model() creates (and discards) a new Model on every init of the view struct. Keep initializers cheap and do real work in .task.
  • Threading. Mark UI models @MainActor so mutations that drive views happen on the main actor; with Swift 6 strict concurrency, the compiler enforces it.
  • .task over onAppear + Task. .task is cancelled when the view disappears, and .task(id:) restarts when the id changes, which is exactly what search-as-you-type needs.
  • Debugging redraws. Call let _ = Self._printChanges() at the top of body (debug builds only) to log which property triggered an update.

Debounce with .task(id:)

.task(id:) cancels the running task when the id changes, which makes it look like a free debounce. Look closely at how cancellation reaches the code:

Predict the output

The user types c, ca, cat, 100 ms apart. How many times is model.search called?

struct SearchScreen: View {
    @State private var query = ""
    @State private var model = SearchModel()

    var body: some View {
        List(model.results) { Text($0.title) }
            .searchable(text: $query)
            .task(id: query) {
                try? await Task.sleep(for: .milliseconds(300))
                await model.search(query)
            }
    }
}

Ids that change on every read

Spot the bugA chat that scrolls to the top

Tap the line with the bug.

struct Message: Identifiable {
    let id = UUID()
    let text: String
}

@Observable @MainActor
final class ChatModel {
    private(set) var texts: [String] = []
    var messages: [Message] { texts.map { Message(text: $0) } }
}

struct ChatView: View {
    let model: ChatModel

    var body: some View {
        List(model.messages) { MessageRow(message: $0) }
    }
}

Exercisemedium

An infinite feed

Build the state layer for a feed that loads 20 posts per page from api.posts(after: cursor) and loads the next page as the user nears the end. It must:

  • show a full-screen spinner and a full-screen error only for the first page,
  • show a small footer spinner, or a footer “Retry” button, for later pages,
  • never load the same page twice, even when several rows appear at once,
  • keep posts and scroll position when a later page fails.

Write the model and the parts of the view that trigger loading.

›

Model the first load and later pages differently. A single isLoading: Bool plus error: Error? can’t express “we have posts, and the last page failed”.

›

Trigger loading from .task (or .onAppear) on the last few rows, and let the model ignore calls while a page is in flight or when there’s no next cursor. The guard lives in the model, so the view can call it as often as it likes.

SolutionTry it yourself first
@Observable @MainActor
final class FeedModel {
    enum Phase: Equatable {
        case loadingFirstPage
        case failedFirstPage(String)
        case loaded
    }
    enum PageState: Equatable { case idle, loading, failed, endReached }

    private(set) var posts: [Post] = []
    private(set) var phase: Phase = .loadingFirstPage
    private(set) var nextPage: PageState = .idle
    private var cursor: String?

    private let api: FeedAPI
    init(api: FeedAPI) { self.api = api }

    func loadFirstPage() async {
        phase = .loadingFirstPage
        do {
            let page = try await api.posts(after: nil)
            posts = page.posts
            cursor = page.nextCursor
            nextPage = page.nextCursor == nil ? .endReached : .idle
            phase = .loaded
        } catch {
            phase = .failedFirstPage(error.localizedDescription)
        }
    }

    func loadMoreIfNeeded(currentPost: Post) async {
        guard phase == .loaded, nextPage == .idle,
              let cursor,
              let index = posts.firstIndex(where: { $0.id == currentPost.id }),
              index >= posts.count - 5 else { return }

        nextPage = .loading                       // set before the await: no second request
        do {
            let page = try await api.posts(after: cursor)
            let known = Set(posts.map(\.id))
            posts += page.posts.filter { !known.contains($0.id) }   // pages can overlap
            self.cursor = page.nextCursor
            nextPage = page.nextCursor == nil ? .endReached : .idle
        } catch is CancellationError {
            nextPage = .idle
        } catch {
            nextPage = .failed
        }
    }

    func retryNextPage() async {
        guard nextPage == .failed, let last = posts.last else { return }
        nextPage = .idle
        await loadMoreIfNeeded(currentPost: last)
    }
}

struct FeedView: View {
    @State private var model: FeedModel

    init(api: FeedAPI) {
        _model = State(initialValue: FeedModel(api: api))
    }

    var body: some View {
        content.task { await model.loadFirstPage() }   // once, when the screen appears
    }

    @ViewBuilder private var content: some View {
        switch model.phase {
        case .loadingFirstPage:
            ProgressView()
        case .failedFirstPage(let message):
            ContentUnavailableView {
                Label("Couldn't load the feed", systemImage: "wifi.exclamationmark")
            } description: { Text(message) } actions: {
                Button("Try again") { Task { await model.loadFirstPage() } }
            }
        case .loaded:
            List {
                ForEach(model.posts) { post in
                    PostRow(post: post)
                        .task { await model.loadMoreIfNeeded(currentPost: post) }
                }
                footer
            }
        }
    }

    @ViewBuilder private var footer: some View {
        switch model.nextPage {
        case .loading: ProgressView().frame(maxWidth: .infinity)
        case .failed: Button("Retry") { Task { await model.retryNextPage() } }
        case .idle, .endReached: EmptyView()
        }
    }
}

Why it holds up:

  • The guard and the state change happen before the first await. On the main actor, nothing can run between them, so two rows appearing together can’t start two requests. After an await, assume anything may have changed.
  • Two state machines, one for the screen and one for the next page, so a failed page 5 never replaces 80 loaded posts with an error screen.
  • Stable server ids and deduplication: cursors can return an overlapping item when new posts arrive, and duplicate ids in ForEach cause undefined behavior.
  • .task on rows is cancelled when the row scrolls away. The model handles CancellationError by going back to .idle, so the next row can try again.

Deep dives

Deep diveWhat the @Observable macro generatesInterview favorite

@Observable rewrites each stored property into a computed one backed by a private stored property, and adds an ObservationRegistrar. The getter calls access(keyPath:) on the registrar; the setter wraps the change in withMutation(keyPath:).

SwiftUI evaluates each body inside withObservationTracking, which records every key path accessed during that evaluation. When one of them is mutated, the tracked view is invalidated once, and tracking is set up again on the next body run. That’s why a view that only reads cart.items.count isn’t redrawn when cart.address changes, and why reads in body (not in a .task) are what count.

WrapperOwns data?Typical use
@StateYesView-local values, and owning an @Observable model
@BindingNoChild edits parent’s value
let (model)NoRead an @Observable model; reads are tracked
@BindableNo$ bindings into an @Observable model
@Environment(M.self)NoApp-wide models, injected with .environment(m)

Gotchas: @State ignores later initial values from the parent. A plain var model = Model() resets on every parent redraw. if/else branches and changing .id reset state. @State var m = Model() runs Model.init on every view init.

@Observable @MainActor
final class SearchModel {
    var query = ""
    private(set) var results: [Item] = []

    func search() async {
        results = (try? await api.search(query)) ?? []
    }
}

struct SearchScreen: View {
    @State private var model = SearchModel()             // the owner

    var body: some View {
        SearchField(model: model)
            .task(id: model.query) { await model.search() } // cancels and restarts per query
    }
}

struct SearchField: View {
    @Bindable var model: SearchModel                       // needs $ bindings

    var body: some View {
        TextField("Search", text: $model.query)
    }
}

Check this part

  1. Which view should hold an @Observable model with @State?

  2. A screen declares var model = ProfileModel() without @State. What happens when its parent redraws?

  3. With @Observable, a badge reads only cart.items.count. Then cart.address changes. Does the badge's body run again?

  4. How often does @State private var model = Model() call Model.init?

Shared data lives in an @Observable class. The view that creates it keeps it in @State, readers take a let, editors take @Bindable, and app-wide models go in the environment. Observation redraws only views that read a changed property.

Key takeaways

  • In SwiftUI you change state, not views; body is redrawn from the state.
  • @State for what a view owns, @Binding to let a child edit it, @Observable classes for shared models.
  • The view that creates a model owns it with @State; others get it as let, @Bindable or from @Environment.
  • State lives as long as the view’s identity: branches and .id changes reset it.
  • @State storage lives in SwiftUI’s view graph, keyed by identity: if/else branches and .id changes reset it.
  • Observation tracks reads per body, so invalidation is per property, not per object.
  • @State var m = Model() runs Model.init on every view init: keep it cheap and load in .task.
  • Make UI models @MainActor and use .task(id:) for loads that must cancel and restart.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.