On this page 9 sections
- Start with one question: who owns this value?
- Observable models with the Observation framework
- Owning the model in a view with @State
- Passing models down: plain properties, @Bindable and @Environment
- Where services and dependencies live
- Navigation as state
- How big should a model be?
- Common mistakes and how to spot them
- 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:
- View state. Whether a sheet is showing, the text in a search field before it is submitted, which tab is selected. It belongs to one view and nothing else cares about it. Keep it in
@Stateinside that view. - Feature state. The list of orders on an orders screen, whether it is loading, the last error. Several views in a feature read it, and it comes from somewhere slower than memory. Put it in an observable model that the feature’s root view owns.
- App state. The signed-in account, user settings, feature flags. Many features read it. Create it once at the top of the app and pass it down through the environment.
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:
- The model is
@MainActor. It drives the UI, so all of its state should change on the main actor. In a project created with Xcode 26 or later, main-actor isolation can be the default for the whole app target; writing it explicitly keeps the intent obvious either way. The Swift concurrency guide covers this in more detail. isLoadinganderrorMessageareprivate(set). Views can read them but only the model can change them, so there is one place to look when a spinner sticks.- The service is a protocol. The model doesn’t know whether books come from a server, a local database or a test stub.
visibleBooksis a computed property. Because Observation tracks the stored properties it reads (booksandsearchText), views that use it still update correctly.
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.
Navigation as state
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:
- it has properties that different screens use and nothing else in the model touches;
- tests for it need a long setup before they reach the behaviour you care about;
- two people regularly edit it at the same time for unrelated features.
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
- A view does work in
body. Sorting, filtering big arrays or formatting dates insidebodyruns every time the view refreshes. Move it into a computed property on the model or precompute it when the data loads. The performance guide shows how to find these in Instruments. - Two sources of truth. A view copies a model value into its own
@StateinonAppear, then edits the copy. The two drift apart. Use a binding to the original. - Everything in one environment object. A single
AppStatewith fifty properties makes every feature depend on every other. Split by domain: account, settings, cart. - Mixing old and new observation.
@StateObjectand@ObservedObjectare forObservableObjecttypes. With@Observablemodels use@State, plain properties,@Bindableand@Environment. Mixing them compiles in some cases but behaves in confusing ways.
Key takeaways
- Decide one owner for every value: view state in
@State, feature state in an@Observablemodel owned by the feature’s root view, app state in the environment. - Read observable models through plain properties; use
@Bindableonly when you need bindings. - Keep models on the main actor and pass services in as protocols, so you can preview and test every state.
- Treat navigation as state too, and put the path where deep links can reach it.
- Add architecture layers only when a real problem asks for them.
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.