Skip to content

SwiftUI Observation, environment, and dependency flow

Observation tells SwiftUI which model reads can affect a view. Dependency injection tells the application where those models and their services come from. They solve related but different problems.

Keep the model on the main actor

import Observation
@MainActor
@Observable
final class NoteListModel {
private let library: NoteLibrary
private(set) var notes: [FieldNote] = []
private(set) var isLoading = false
private(set) var errorMessage: String?
init(library: NoteLibrary) {
self.library = library
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
notes = try await library.notes()
errorMessage = nil
} catch is CancellationError {
return
} catch {
errorMessage = "Notes could not be loaded."
}
}
}

The model owns presentation state and coordinates a library contract. The library owns domain operations. The view does not know whether the repository is in memory, on disk, or remote.

Let the app own shared models

@main
struct FieldNotesApp: App {
@State private var model = NoteListModel(
library: NoteLibrary(repository: InMemoryNoteRepository())
)
var body: some Scene {
WindowGroup {
NoteListView(model: model)
}
}
}

@State gives this observable model a lifetime tied to the scene’s view identity. A child can accept it as plain input:

struct NoteListView: View {
let model: NoteListModel
var body: some View {
List(model.notes) { note in
Text(note.title)
}
.overlay {
if model.isLoading { ProgressView() }
}
.task { await model.load() }
}
}

SwiftUI tracks the observable properties read while forming this view. Reading notes and isLoading establishes relevant dependencies.

Use bindable access for editable model properties

When a control needs a binding into an observable model, create bindable access at the editing boundary:

struct SettingsView: View {
@Bindable var model: SettingsModel
var body: some View {
Toggle("Show archived notes", isOn: $model.showsArchived)
}
}

Do not expose mutation merely because a property is observable. Keep setters private when children only need to read, and expose named methods when changes require validation or side effects.

Use environment for scoped context

Environment values fit dependencies that many descendants need within a clear interface scope, such as locale, dismiss behavior, or a deliberately injected application context.

They are a poor excuse for an invisible service registry. If any view can pull networking, persistence, analytics, and navigation from global environment state, dependencies become difficult to discover and tests need oversized setup.

Prefer explicit initializer injection for feature services and models. Use environment when the value is genuinely ambient and has a sensible scoped default or a deliberate missing-value failure.

Build deterministic alternatives

let previewModel = NoteListModel(
library: NoteLibrary(
repository: InMemoryNoteRepository(seed: .previewNotes)
)
)

Previews and tests get local fixtures. Production composition selects the disk-backed adapter later. Neither interface code nor domain code needs credentials or a cloud service.

Review the dependency graph

SwiftUI view
|
v
observable presentation model
|
v
application or domain contract
|
v
in-memory, disk, or network adapter

Dependencies point inward. Observation does not erase those layers. It only makes UI updates follow relevant property reads.

Validation boundary

The existing companion app uses this broad composition shape, but this post’s snippets were not compiled with Xcode. Observation integration, SwiftUI invalidation, previews, and Simulator behavior remain Not verified.

Series navigation

References