Start a project

Article · iOS · SwiftUI · Architecture · 9 min read

SwiftUI state and app architecture with Observation

Most SwiftUI apps that become hard to change got that way for one reason: nobody decided where each piece of state lives. This guide sets out a structure that stays readable from the first screen to the fiftieth, using the Observation framework that arrived with iOS 17 and the property wrappers SwiftUI gives you for it.

On this page 9 sections
  1. Start with one question: who owns this value?
  2. Observable models with the Observation framework
  3. Owning the model in a view with @State
  4. Passing models down: plain properties, @Bindable and @Environment
  5. Where services and dependencies live
  6. Navigation as state
  7. How big should a model be?
  8. Common mistakes and how to spot them
  9. Key takeaways

Start with one question: who owns this value?

Every piece of state in a SwiftUI app has exactly one owner. Everything else either reads it or gets a binding to change it. If you can answer “who owns this?” for each value on a screen, most architecture problems disappear before they start.

There are three kinds of state worth separating:

A common mistake is app state leaking into views as scattered @State copies, or view state pushed up into a giant shared model “just in case”. Both make bugs harder to find, because a value can change from places you didn’t expect.

Observable models with the Observation framework

Before iOS 17, observable models used ObservableObject and @Published, and any change to any published property refreshed every view that held the object. The Observation framework replaces that with the @Observable macro. SwiftUI now tracks which properties a view’s body actually reads, and only refreshes that view when one of those properties changes.

Here is a feature model for a simple reading list:

import Observation

struct Book: Identifiable, Hashable, Sendable {
    let id: UUID
    var title: String
    var author: String
}

protocol BookService: Sendable {
    func fetchBooks() async throws -> [Book]
}

@MainActor
@Observable
final class LibraryModel {
    var books: [Book] = []
    var searchText = ""
    private(set) var isLoading = false
    private(set) var errorMessage: String?

    private let service: any BookService

    init(service: any BookService) {
        self.service = service
    }

    var visibleBooks: [Book] {
        guard !searchText.isEmpty else { return books }
        return books.filter { $0.title.localizedStandardContains(searchText) }
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }
        do {
            books = try await service.fetchBooks()
            errorMessage = nil
        } catch {
            errorMessage = "Couldn't load your books. Pull down to try again."
        }
    }
}

A few choices in there are deliberate:

Owning the model in a view with @State

The feature’s root view creates and owns the model. With Observation, ownership uses the same @State wrapper you use for simple values:

import SwiftUI

struct LibraryView: View {
    @State private var model: LibraryModel

    init(service: any BookService) {
        _model = State(initialValue: LibraryModel(service: service))
    }

    var body: some View {
        NavigationStack {
            List(model.visibleBooks) { book in
                NavigationLink(value: book) {
                    BookRow(book: book)
                }
            }
            .navigationTitle("Library")
            .navigationDestination(for: Book.self) { book in
                BookDetailView(book: book)
            }
            .searchable(text: $model.searchText)
            .overlay {
                if model.isLoading && model.books.isEmpty {
                    ProgressView()
                } else if let message = model.errorMessage, model.books.isEmpty {
                    ContentUnavailableView("No books", systemImage: "books.vertical", description: Text(message))
                }
            }
            .task { await model.load() }
            .refreshable { await model.load() }
        }
    }
}

@State keeps the model alive for as long as the view’s identity lasts, even when SwiftUI recreates the LibraryView struct many times. $model.searchText gives a binding straight into the model’s property, which is what searchable needs.

One detail is worth knowing: SwiftUI evaluates the initial value you pass to State(initialValue:) whenever the view struct is initialised, then throws it away after the first time. For a cheap model that’s fine. If creating the model is expensive, create it higher up and pass it in, or create it lazily in .task.

.task starts loading when the view appears and cancels the work automatically if the view disappears first. .refreshable gives you pull to refresh for free, and because load() is async, the refresh indicator stays visible until it finishes.

Passing models down: plain properties, @Bindable and @Environment

Child views don’t need a property wrapper just to read an observable model. A plain let is enough, and SwiftUI still tracks what the child’s body reads:

struct LibrarySummary: View {
    let model: LibraryModel

    var body: some View {
        Text("\(model.books.count) books")
    }
}

When a child needs to change a property through a control such as a Toggle or TextField, use @Bindable. It adds the $ projection so you can make bindings:

@MainActor
@Observable
final class ReadingSettings {
    var showFinished = true
    var dailyGoalMinutes = 20
}

struct ReadingSettingsForm: View {
    @Bindable var settings: ReadingSettings

    var body: some View {
        Form {
            Toggle("Show finished books", isOn: $settings.showFinished)
            Stepper("Daily goal: \(settings.dailyGoalMinutes) min",
                    value: $settings.dailyGoalMinutes, in: 5...120, step: 5)
        }
    }
}

For app-wide state, put the model in the environment once and read it wherever it’s needed:

@main
struct ReadingApp: App {
    @State private var settings = ReadingSettings()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(settings)
        }
    }
}

struct FinishedFilterButton: View {
    @Environment(ReadingSettings.self) private var settings

    var body: some View {
        @Bindable var settings = settings
        Toggle("Finished", isOn: $settings.showFinished)
    }
}

The @Bindable var settings = settings line inside body is the documented way to get bindings to an environment object. If a view reads @Environment(ReadingSettings.self) and nothing has provided one, the app stops with a runtime error, so inject app-wide models at the root and in every preview that needs them.

Apple’s article Managing model data in your app walks through the same three tools with more examples.

Where services and dependencies live

A model that calls URLSession.shared directly can’t be tested without a network and can’t be previewed without one either. Pass dependencies in through the initialiser, as LibraryModel does with BookService, and make one concrete implementation for the real app:

struct RemoteBookService: BookService {
    let baseURL: URL
    let session: URLSession

    func fetchBooks() async throws -> [Book] {
        let url = baseURL.appending(path: "books")
        let (data, response) = try await session.data(from: url)
        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }
        return try JSONDecoder().decode([BookDTO].self, from: data).map(\.book)
    }
}
private struct BookDTO: Decodable {
    let id: UUID
    let title: String
    let author_name: String

    var book: Book { Book(id: id, title: title, author: author_name) }
}

A small amount of decoding glue (BookDTO here) between the server’s JSON and your app’s types is worth having. When the server changes a field name, you fix one mapping rather than every screen.

For previews and tests, make a stub:

struct StubBookService: BookService {
    var result: Result<[Book], Error>

    func fetchBooks() async throws -> [Book] {
        try result.get()
    }
}

#Preview("Loaded") {
    LibraryView(service: StubBookService(result: .success([
        Book(id: UUID(), title: "The Left Hand of Darkness", author: "Ursula K. Le Guin")
    ])))
}

#Preview("Failed") {
    LibraryView(service: StubBookService(result: .failure(URLError(.notConnectedToInternet))))
}

Two previews, two states, no network. Building previews for the empty, loading, error and full states of every screen is one of the cheapest quality habits in SwiftUI work.

With NavigationStack, the navigation path is just more state, and it follows the same ownership rule. If only one feature pushes screens, keep the path in that feature. If deep links or notifications need to open a screen, the path has to live somewhere they can reach, usually an app-level router:

enum Route: Hashable {
    case book(Book.ID)
    case author(String)
}

@MainActor
@Observable
final class Router {
    var path: [Route] = []

    func open(_ url: URL) {
        guard url.scheme == "reading", url.host() == "book",
              let id = UUID(uuidString: url.lastPathComponent) else { return }
        path = [.book(id)]
    }
}

The root view binds NavigationStack(path: $router.path) and handles .onOpenURL { router.open($0) }. Because the route is a plain value, you can also save it and restore the user’s place after the app is relaunched.

How big should a model be?

A model per screen is a sensible default. Split one up when:

Merge two models when one is just passing values to the other. There is no prize for the number of layers. Patterns such as MVVM, TCA or a coordinator layer each solve real problems, but adopting one wholesale before the app needs it adds code that every future change has to pass through. Start with views, observable models and services, and add structure where a specific pain appears.

Common mistakes and how to spot them

Key takeaways

If you have a SwiftUI app that has grown harder to change than it should be, or you’re planning a new one and want the structure right from the start, tell me about it. I’m happy to look at what you have and suggest the smallest change that helps.

Written by Lee Johnson. Published 30 October 2026.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project