SwiftModel is a library for composing models that drive SwiftUI views, with powerful features and advanced tooling using a lightweight modern Swift style.
- What is SwiftModel
- Models and Composition
- SwiftUI Integration
- Dependencies
- Lifetime and Asynchronous Work
- Undo and Redo
- Events
- Testing
Much like SwiftUI's composition of views, SwiftModel uses well-integrated modern Swift tools for composing your app's different features into a hierarchy of models. Under the hood, SwiftModel keeps track of model state changes, dependencies, and ongoing asynchronous work. This results in several advantages:
- Natural injection and propagation of dependencies down the model hierarchy.
- Support for sending events up or down the model hierarchy.
- Exhaustive testing of state changes, events and concurrent operations.
- Integrates fully with modern Swift concurrency with extended tools for powerful lifetime management.
- Fine-grained observation of model state changes.
SwiftModel is an evolution of the Swift One State library, where the introduction of Swift macros allows a more lightweight syntax.
SwiftModel takes inspiration from similar architectures such as The Composable Architecture, but aims to be less esoteric by using a more familiar style.
SwiftModel requires Swift 5.9.2 (Xcode 15.1) that fixes compiler bugs around the new init accessor
Even more init accessor compiler fixes did land in Swift 5.10, but there still some remaining fixes that did not make it to 5.10. Until then @Model custom initializers might require accessing the underscored private members directly instead of the regular ones.
Below we will build parts of a sample app that you can see as a whole in the Examples folder of this repository.
The examples shown below are mostly from the example app CounterFact, but some more advanced examples comes from the Standups app.
Models are the central building block in SwiftModel. A model declares state together with operations, that composes with other models to provide a model hierarchy for propagating dependencies and events.
You use the SwiftModel's macro @Model to set your type up for model composition and observation tracking.
import SwiftModel
@Model struct CountModel {
var count = 0
func decrementTapped() {
count -= 1
}
func incrementTapped() {
count += 1
}
}Note that your model type is required to be a struct, even though its behavior is more like a reference type such as a class. This is required to unlock the powerful state tracking used in testing and debugging, and to avoid retain cycles that are common with reference types.
Because models are structs (value types), SwiftModel eliminates the retain-cycle problem that affects class-based architectures. You never need [weak self] capture lists — the compiler won't even accept them since you cannot take a weak reference to a struct. More practically, you can freely store callback closures directly as let properties and capture self in any closure without risk of a memory leak:
@Model struct RecordMeetingModel {
let onSave: @Sendable (String) -> Void
let onDiscard: @Sendable () -> Void
}
@Model struct AppModel {
var recording: RecordMeetingModel? = nil
func startRecording() {
recording = RecordMeetingModel(
onSave: { transcript in
self.saveTranscript(transcript) // safe — no retain cycle
},
onDiscard: {
self.recording = nil // safe — no retain cycle
}
)
}
}This works because each @Model struct holds only a weak reference to its underlying context. The strong ownership lives in the model hierarchy (parent → child), so closures that capture self cannot create cycles.
A model can be composed by other models where the most common composition is to have either an inline model, an optional model, or a collection of models.
@Model struct CounterRowModel {
var counter = CountModel()
}@Model struct AppModel {
var counters: [CounterRowModel] = []
var factPrompt: FactPromptModel? = nil
var sum: Int {
counters.reduce(0) { $0 + $1.counter.count }
}
}A model has an identity and conforms to
Identifiableusing a default generated id unless overridden by your model. This is e.g. used to identify models in arrays.
Often it is more convenient and safer to use an
IdentifiedArrayinstead of a plain array.
For SwiftModel to be able to detect a composition of models, any container holding other models (directly or indirectly) needs to conform to the ModelContainer protocol. This is part of what the @Model macro provides a model, but if you nest models insides custom enum and struct types, SwiftModel provides the @ModelContainer macro:
@ModelContainer enum Path {
case detail(StandupDetail)
case meeting(Meeting, standup: Standup)
case record(RecordMeeting)
}@ModelContainer works equally well on structs, which makes it useful for reusable wrapper types. For example, a generic paginated list that holds a model alongside metadata:
@ModelContainer struct Paginated<Item: Model> {
var items: [Item]
var currentPage: Int
var hasMore: Bool
}
@Model struct FeedModel {
var posts: Paginated<PostModel> = Paginated(items: [], currentPage: 0, hasMore: true)
}SwiftModel will correctly traverse into Paginated.items for activation, observation, and event propagation — the wrapper is transparent to the model hierarchy.
A model is backed by a behind the scenes context that holds a model's shared state, its relation to other models, overridden dependencies etc.
This context is weakly held by the model which helps avoiding memory cycles when e.g. using callback closures. A model will hold a strong reference to its children, but someone has to hold a strong reference to the root model. This is typically done by using an explicit withAnchor() modifier on the root model.
struct MyApp: App {
let model = AppModel().withAnchor()
var body: some Scene {
WindowGroup {
AppView(model: model)
}
}
}If a view is not called more than once, you can create the model with an anchor inline:
#Preview {
CounterView(model: CounterModel().withAnchor())
}If you need to keep a reference to both the model and the anchor separately, use andAnchor():
let (model, anchor) = AppModel().andAnchor()A SwiftModel model goes through different life stages. It starts out in the initial state. This is typically just for a really brief period between calling the initializer and being added to a model hierarchy of anchored models.
func addButtonTapped() {
let row = CounterRowModel(...) // Initial state
counters.append(row) // row is anchored
}Once an initial model is added to an anchored model, it is set up with a supporting context and becomes anchored.
If the model is later removed from the parent's anchored model, it will lose its supporting context and enter a destructed state.
A model can also be copied into a frozen copy where the state will become immutable. This is used e.g. when printing state updates, and while running unit tests, to be able to compare previous states of a model with later ones.
The Model protocol provides an onActivate() extension point that is called by SwiftModel once the model becomes part of anchored model hierarchy. This is a perfect place to populate a model's state from its dependencies and to set up listeners on child events and state changes.
Any parent will always be activated before its children to allow the parent to set up listeners on child events and value changes. Once a parent is deactivated it will cancel its own activities before deactivating its children.
func onActivate() {
if standup.attendees.isEmpty {
standup.attendees.append(Attendee(id: Attendee.ID(node.uuid())))
}
}You can also compose activation logic from the outside using the withActivation(_:) modifier. This is useful when you want to attach behavior to a model without modifying its source, or when building test setups and previews:
let model = StandupModel()
.withActivation { $0.loadFromDisk() }
.withAnchor()Multiple withActivation calls are additive — each closure runs in order when the model activates.
A model is typically instantiated and assigned to one place in a model hierarchy, but sometimes it can be useful to share a model in different parts of a model hierarchy.
SwiftModel supports sharing with the following implications:
- A shared model will inherit the dependencies at its initial point of entry to the model hierarchy.
- The shared model is activated on initial anchoring and deactivated once the last reference of the model is removed.
- An event sent from a shared model will be coalesced and receivers will only see a single event (even though it was sent from all its locations in the model hierarchy).
- Similarly a shared model will only receive sent events at most once.
As SwiftModels keeps track of all model state changes, it supports printing of differences between previous and updated state. You can enable this for the lifetime of a model by adding a modifier:
AppModel()._withPrintChanges()Or if you only want to print these updates for a period of time:
let printTask = model._printChanges()
await workToTrack()
printTask.cancel()Printing of changes are only active in
DEBUGbuilds.
SwiftModel has been designed to integrate well with SwiftUI. Where you typically conform your models to ObservableObject in plain vanilla SwiftUI projects, and get access and view updates by using @ObservedObject in your SwiftUI views. In SwiftModel you instead apply @Model to your models and use @ObservedModel to trigger your views to update on state changes.
struct CounterView: View {
@ObservedModel var model: CounterModel
var body: some View {
HStack {
Button("-") { model.decrementTapped() }
Text("\(model.count)")
Button("+") { model.incrementTapped() }
}
}
}Access to embedded models and derived properties are straight forward as well.
struct AppView: View {
@ObservedModel var model: AppModel
var body: some View {
ZStack(alignment: .bottom) {
List {
Text("Sum: \(model.sum)")
ForEach(model.counters) { row in
CounterRowView(model: row)
}
}
if let factPrompt = model.factPrompt {
FactPromptView(model: factPrompt)
}
}
}
}
@ObservedModelhas been carefully crafted to only trigger view updates when properties you are accessing from your view is updated. In comparison,@ObservedObjectwill trigger a view update no matter what@Publishedproperty is updated in yourObservableObjectmodel object.
On iOS 17+, macOS 14+, tvOS 17+, and watchOS 10+, SwiftModel provides enhanced compatibility with Swift's native observation infrastructure. The @Model macro works seamlessly with types that conform to the Observable protocol (typically via the @Observable macro). Models automatically integrate with the platform's ObservationRegistrar when available, providing seamless observation support.
When you need @ObservedModel:
- Always needed if you require bindings to your model's properties (e.g., for forms, text fields, steppers)
- Optional on iOS 17+ for observation-only use cases (reading properties without creating bindings)
Why Apple's @Bindable doesn't work with Models:
Apple's @Bindable property wrapper (introduced in iOS 17+) is designed to work with reference types (classes) that conform to the Observable protocol. However, SwiftModel's @Model types are value types (structs) with reference semantics. While the Observable protocol itself doesn't require reference types, Apple chose to restrict @Bindable's initializers to only accept classes. This design decision means you cannot use @Bindable with Models.
For bindings with Models, continue to use @ObservedModel on all iOS versions, which provides the same binding capabilities as @Bindable while also supporting SwiftModel's value-type architecture.
The @ObservedModel also expose bindings to a model's properties:
Stepper(value: $model.count) {
Text("\(model.count)")
}For improved control of a model's dependencies to outside systems, such as backend services, SwiftModel has a system where a model can access its dependencies without needing to know how they were configured or set up. This is very similar to how SwiftUI's environment is working.
This has been popularized by the swift-dependency package which SwiftModel integrates with.
You define a dependency type by conforming it to DependencyKey where you provide a default value:
import Dependencies
struct FactClient {
var fetch: @Sendable (Int) async throws -> String
}
extension FactClient: DependencyKey {
static let liveValue = FactClient(
fetch: { number in
let (data, _) = try await URLSession.shared.data(from: URL(string: "http://numbersapi.com/\(number)")!)
return String(decoding: data, as: UTF8.self)
}
)
}A model accesses its dependencies via its node.
let fact = try await node[FactClient.self].fetch(count)
nodeis the model implementor's interface to the SwiftModel runtime. It provides access to dependencies, async tasks, events, cancellations, memoization, and hierarchy queries. It is intended to be used from within a model's own implementation — inonActivate(), in methods, and in extensions — not by external consumers of the model.
There is also a convenience macro for dependencies:
@Model struct CounterModel {
@ModelDependency var factClient: FactClient
}By also extending DependencyValues you will get more convenient access to commonly used dependencies:
extension DependencyValues {
var factClient: FactClient {
get { self[FactClient.self] }
set { self[FactClient.self] = newValue }
}
}
let fact = try await node.factClient.fetch(count)When anchoring your root model you can provide a trailing closure where you can override default dependencies. This is especially useful for testing and previews.
let model = AppModel().withAnchor {
$0.factClient.fetch = { "\($0) is a great number!" }
}Any descendant models will inherit its parent’s dependencies.
You can also override a child model's dependencies (and its descendants) using the withDependencies() modifier:
appModel.factPrompt = FactPromptModel(...).withDependencies {
$0.factClient.fetch = { "\($0) is a great number!" }
}A @Model type can also be used as a dependency by conforming to DependencyKey. When accessed via @ModelDependency (or node[Dep.self]), SwiftModel integrates it into the context hierarchy as a shared model — one instance, shared across all consumers.
@Model struct AnalyticsService {
var isEnabled: Bool = true
func track(_ event: String) { ... }
}
extension AnalyticsService: DependencyKey {
static let liveValue = AnalyticsService()
static let testValue = AnalyticsService() // used automatically in tests
}
@Model struct FeatureModel {
@ModelDependency var analytics: AnalyticsService
}Alternatively — and in practice the most convenient approach — extend DependencyValues just like you would for a plain dependency:
extension DependencyValues {
var analyticsService: AnalyticsService {
get { self[AnalyticsService.self] }
set { self[AnalyticsService.self] = newValue }
}
}This lets every model access the dependency directly via node, with no @ModelDependency property needed:
func onActivate() {
node.analyticsService.track("app_launched")
node.forEach(Observed { node.analyticsService.isEnabled }) { isEnabled in
// react to changes
}
}Overriding works the same way, using either the key path or the subscript form:
let model = AppModel().withAnchor {
$0.analyticsService = AnalyticsService(isEnabled: false) // key path
// or: $0[AnalyticsService.self] = AnalyticsService(isEnabled: false)
}Lifecycle. The dependency model's onActivate() is called when it is first accessed by any model in the hierarchy. It is deactivated when the last host model is removed. Multiple models that access the same dependency type receive the same shared context — onActivate() runs once and onCancel fires once.
Observation. A host model can observe properties of the dependency model via Observed, exactly as it would observe any child model's properties:
func onActivate() {
node.forEach(Observed { analytics.isEnabled }) { isEnabled in
// fires whenever analytics.isEnabled changes
}
}Events from the dependency. Events sent by the dependency model with the default to: [.self, .ancestors] travel up to the host model's event listeners, because the dependency context has the host as a parent.
Events to the dependency. The default node.send(event) relation [.self, .ancestors] does not reach dependency models. To deliver an event to a dependency, you must include both .children and .dependencies in the relation:
node.send(MyEvent.refresh, to: [.self, .children, .dependencies])Hierarchy traversal. reduceHierarchy and mapHierarchy do not visit dependency models by default. Include .dependencies alongside .descendants or .children to traverse them:
let services = node.mapHierarchy(for: [.self, .descendants, .dependencies]) {
$0 as? AnalyticsService
}A typical model will need to handle asynchronous work such as performing operations and listening on updates from its dependencies. It is also common to listen to model events and state changes that SwiftModel exposes as asynchronous streams.
SwiftModel is fully thread safe, and supports working with your models and their state from any task context. SwiftUI helpers such as
@ObservedModelwill make sure to only update views from the@MainActorthat is required by SwiftUI.
To start some asynchronous work that is tied to the life time of your model you call node.task(), similarly as you would do when adding a task() to your view.
@Model struct CounterModel {
let count: Int
let onFact: (Int, String) -> Void
var alert: Alert?
func factButtonTapped() {
node.task {
let fact = try await node.factClient.fetch(count)
onFact(count, fact)
} catch: { error in
alert = Alert(message: "Couldn't load fact.", title: "Error")
}
}
} SwiftModel provides the Observed API for creating asynchronous streams that emit whenever observed model properties change. This is useful for reacting to state changes within your model logic.
func isPrime(_ value: Int) async throws -> Bool { ... }
node.forEach(Observed { count }) { count in
state.isPrime = nil // Show spinner
state.isPrime = try await isPrime(count)
}The Observed stream automatically tracks which properties are accessed in its closure and will emit a new value whenever any of those properties change. For Equatable types, duplicate values are filtered out by default.
forEach will by default complete its asynchronous work before handling the next value, but sometimes it is useful to cancel any previous work that might become outdated.
node.forEach(Observed { count }, cancelPrevious: true) { count in
state.isPrime = nil // Show spinner
state.isPrime = try await isPrime(count)
}
cancelPreviousvscancelInFlight(): these solve similar but distinct problems.
cancelPrevious: trueonforEachcontrols per-element parallelism — each new value from the sequence cancels the async work for the previous value. It's about keeping the handler up-to-date as values stream in.cancelInFlight()on aCancellablecontrols call-site deduplication — calling the same function again cancels the task started by the previous call. It's about ensuring only one instance of a task runs at a time, regardless of any input stream.
You can also use Observed directly as an AsyncSequence:
let countStream = Observed { model.count }
for await count in countStream {
print("Count changed to: \(count)")
}For Equatable properties, writing the same value that is already stored is a no-op: no observers are notified and the property value is unchanged. This is an intentional optimisation — it prevents cascading re-renders and avoids unnecessary work when a value is conditionally set to what it already holds.
model.count = 5 // count is already 5 — observers are not notified
model.count = 7 // count changed — observers are notifiedSometimes external state that a property depends on changes in a way that is invisible to the equality check — for example, a reference-typed backing store that is mutated in-place. In those cases, call node.touch(\.property) to notify all registered observers of that property as if its value had changed, without actually modifying it:
// Mutate external backing store directly — equality check would suppress notification
externalDocument.unsafeReplace(newContent)
node.touch(\.document) // Force dependents of `document` to re-readnode.touch(\.property) fires the observation callbacks for the given property and bypasses the Equatable deduplication check, so Observed streams and SwiftUI views that depend on that property will re-evaluate even if the observed value compares equal to its previous result.
SwiftModel provides node.memoize() for creating cached computed properties that automatically invalidate and recompute when their dependencies change. This is particularly useful for expensive computations.
@Model struct DataModel {
var items: [Item] = []
var processedData: [ProcessedItem] {
node.memoize(for: "processedData") {
// Expensive computation only runs when items changes
items.map { processItem($0) }
}
}
}Memoize automatically:
- Caches the result of the computation
- Tracks dependencies accessed during the computation
- Invalidates the cache when any dependency changes
- Recomputes only when the cached value is accessed after invalidation
- Notifies observers (like SwiftUI views) when the value changes
For Equatable types, you can enable deduplication to prevent unnecessary recomputations when the result would be the same:
var normalized: String {
node.memoize(for: "normalized") {
name.lowercased().trimmingCharacters(in: .whitespaces)
}
}The Equatable overload automatically compares the new result with the cached value and only triggers updates if they differ, even if dependencies changed.
Memoize works seamlessly with SwiftUI's observation system on iOS 17+ and with the AccessCollector mechanism on earlier versions, ensuring views update correctly when memoized values change.
All tasks started from a model are automatically cancelled once the model is deactivated (it is removed from an anchored model hierarchy). But task() and forEach() also return a Cancellable instance that allows you to cancel an operation earlier.
let task = task { ... }
...
task.cancel()A cancellable can also be set up to cancel given a hashable id.
let operationID = "operationID"
func startOperation() {
node.task { ... }.cancel(for: operationID)
}
func stopOperation() {
node.cancelAll(for: operationID)
}By using a cancellation context you can group several operations to allow cancellation of them all as a group:
node.cancellationContext(for: operationID) {
node.task { }
node.forEach(...) { }
}This is particularly useful for multi-step operations where you want to cancel the entire flow as a unit. For example, a "save flow" that spawns a validation task and an upload task can be cancelled atomically:
let saveFlowID = "saveFlow"
func startSave() {
node.cancellationContext(for: saveFlowID) {
node.task { await validate() }
node.task { await upload() }
}
}
func cancelSave() {
node.cancelAll(for: saveFlowID) // cancels both tasks at once
}When a task itself spawns nested work, use .inheritCancellationContext() so the nested work is also cancelled when the parent context is cancelled:
node.task {
node.forEach(updates) { update in
processUpdate(update)
}.inheritCancellationContext() // cancelled when the outer task's context is cancelled
}You can also call node.onCancel { ... } to execute work upon cancellation.
If you perform an asynchronous operation it sometimes makes sense to cancel any already in flight operations.
func startOperation() {
node.task { ... }.cancel(for: operationID, cancelInFlight: true)
}So if you call startOperation() while one is already ongoing, it will be cancelled and new operation is started to replace it.
If you don't need to cancel your operation from somewhere else you can let SwiftModel generate an id for you:
func startOperation() {
node.task { ... }.cancelInFlight()
}The id is created by using the current source location of the
cancelInFlight()call.
As SwiftModel fully embraces Swift concurrency tools, it means that your model is often accessed from several different threads at once. This is safe to do, but sometimes it is important that model state modifications are grouped together to not break invariants. For this SwiftModel provides the node.transaction { ... } helper.
node.transaction {
counts.append(count)
sum = counts.reduce(0, +)
}All mutations inside the block appear atomically to other threads. Observation callbacks (and observeAnyModification() emissions) are deferred until the transaction completes, so observers see only the final consistent state.
No rollback on error: if the closure throws, any mutations already applied inside the block are not rolled back. Wrap the transaction in a
do/catchand handle partial-state recovery manually if needed.
observeAnyModification() returns a stream that emits whenever any state in a model or its descendants changes, without needing to specify which property. This is useful for cross-cutting concerns:
func onActivate() {
// Show unsaved-changes indicator whenever anything in the form changes
node.forEach(observeAnyModification()) { [weak self] _ in
hasUnsavedChanges = true
}
}Multiple mutations inside a node.transaction { } produce a single emission, so rapid batched changes don't cause redundant work. Combined with AsyncAlgorithms you can build debounced autosave:
func onActivate() {
node.task {
for await _ in observeAnyModification().debounce(for: .seconds(2)) {
await autosave()
}
}
}
observeAnyModification()is onModeldirectly (notnode), so you call it asobserveAnyModification()from within a model, orchildModel.observeAnyModification()from a parent model.
If your project uses Combine, node.onReceive(_:) lets you subscribe to any Publisher for the lifetime of the model. The subscription is automatically cancelled when the model is deactivated.
func onActivate() {
node.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refresh()
}
}SwiftModel has built-in support for undo/redo via node.trackUndo(). Call it from onActivate() to register which properties participate in the undo stack. Each modification to a tracked property automatically pushes an entry onto a ModelUndoStack (or any custom UndoBackend), and undoing restores the model to its previous state as a single atomic transaction.
Inject a ModelUndoStack as the undoSystem dependency when anchoring, then call trackUndo() from onActivate():
@Model struct EditorModel {
var title = ""
var body = ""
func onActivate() {
node.trackUndo() // all tracked properties participate in undo
}
}
// At the call site:
let stack = ModelUndoStack()
let model = EditorModel().withAnchor {
$0.undoSystem.backend = stack
}
stack.undo()
stack.redo()There are two ways to track only a subset of properties:
Track specific paths — pass the key paths you want to track:
@Model struct TodoListModel {
var items: [TodoItem] = []
var newItemTitle = "" // ephemeral — not part of undo history
func onActivate() {
node.trackUndo(\.items) // only item changes are undoable
}
}Exclude specific paths — track everything except the listed key paths:
@Model struct EditorModel {
var title = ""
var body = ""
var searchQuery = "" // ephemeral search field
func onActivate() {
node.trackUndo(excluding: \.searchQuery) // all except searchQuery
}
}Child model tracking — each model is responsible for its own properties. Child models that should participate in undo must call trackUndo in their own onActivate:
@Model struct TodoItem {
var title: String
var isDone: Bool = false
func onActivate() {
node.trackUndo(\.title, \.isDone) // tracked by the child itself
}
}ModelUndoSystem exposes canUndo and canRedo as observable model properties that update reactively as the stack changes. Wire them directly in your view:
Button { model.node.undoSystem.undo() } label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.disabled(!model.node.undoSystem.canUndo)
.keyboardShortcut("z", modifiers: .command)
Button { model.node.undoSystem.redo() } label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.disabled(!model.node.undoSystem.canRedo)
.keyboardShortcut("z", modifiers: [.command, .shift])For macOS and iOS apps that want Cmd+Z / Cmd+Shift+Z wired to the system Edit menu automatically, use UndoManagerBackend instead of ModelUndoStack:
struct TodoListView: View {
@ObservedModel var model: TodoListModel
@Environment(\.undoManager) var undoManager
var body: some View {
TodoListContent(model: model)
.task(id: undoManager.map(ObjectIdentifier.init)) {
model.node.undoSystem.backend = undoManager.map(UndoManagerBackend.init)
}
}
}node.uniquelyReferenced() returns a stream that emits true when a model has exactly one owner in the hierarchy and false when it is shared across multiple parents. This enables "exclusive editing" UX patterns — for example, disabling an edit button while a model is referenced from multiple places:
func onActivate() {
node.forEach(node.uniquelyReferenced()) { isExclusive in
isEditable = isExclusive
}
}The stream emits the current value immediately and deduplicates consecutive equal values.
SwiftModel maintains full knowledge of the parent-child relationships between all models in your application. The node.reduceHierarchy and node.mapHierarchy helpers expose this information so you can query or aggregate data across any portion of the hierarchy.
Both helpers accept a ModelRelation option set that controls which models are visited:
| Relation | Description |
|---|---|
.self |
Only the model itself |
.parent |
Direct parents (one hop up) |
.ancestors |
All ancestors recursively (parents, grandparents, …) |
.children |
Direct children (one hop down) |
.descendants |
All descendants recursively |
.dependencies |
Also include dependency models at each visited node |
Relations can be combined freely: [.self, .descendants] visits the model and its entire subtree.
mapHierarchy applies a transform closure to each visited model and collects the non-nil results into an array:
// Find the nearest ancestor AppModel
let appModel = node.mapHierarchy(for: .ancestors) { $0 as? AppModel }.first
// Collect all descendant models of a specific type
let counters = node.mapHierarchy(for: [.self, .descendants]) { $0 as? CounterModel }reduceHierarchy is the general form. It lets you fold results into an accumulator, which is useful when building up non-array results:
// Sum all counts across the descendant subtree
let total = node.reduceHierarchy(
for: [.self, .descendants],
transform: { ($0 as? CounterModel)?.count },
into: 0
) { $0 += $1 }Combining Observed with mapHierarchy or reduceHierarchy creates a stream that automatically tracks both property changes and structural changes across an entire subtree. This is a uniquely powerful pattern that most architectures cannot express without manual subscriptions.
func onActivate() {
// Re-evaluates when count changes on any CounterModel in the hierarchy,
// AND when counters are added or removed.
node.forEach(Observed { node.mapHierarchy(for: [.self, .descendants]) { ($0 as? CounterModel)?.count } }) { counts in
total = counts.reduce(0, +)
}
}The stream re-evaluates in two situations:
- Property change: any
counton any visitedCounterModelchanges - Structural change: a child model is added or removed from the hierarchy
A practical real-world example — a document model that tracks whether any sub-editor has unsaved changes:
@Model struct DocumentModel {
var editors: [EditorModel] = []
var hasUnsavedChanges = false
func onActivate() {
node.forEach(Observed { node.mapHierarchy(for: [.self, .descendants]) { ($0 as? EditorModel)?.isDirty } }) { dirtyFlags in
hasUnsavedChanges = dirtyFlags.contains(true)
}
}
}When a new EditorModel is added to editors, the stream immediately includes its isDirty property in the tracking set — no manual subscription needed.
Context and preferences let models share data across the model hierarchy without explicit parent-to-child passing. Context flows downward (like SwiftUI's @Environment); preferences flow upward (like SwiftUI's PreferenceKey).
Both systems are declared by extending a namespace type with computed properties, and accessed via node.context and node.preference.
Declare a context key by extending ContextKeys with a computed property that returns a ContextStorage descriptor:
extension ContextKeys {
var isFeatureEnabled: ContextStorage<Bool> {
.init(defaultValue: false)
}
}Read and write via node.context:
node.context.isFeatureEnabled = true // write
let enabled = node.context.isFeatureEnabled // readFor values that should automatically flow to all descendants — like a colour scheme, a selection state, or an editing mode — use .environment propagation:
extension ContextKeys {
var colorScheme: ContextStorage<ColorScheme> {
.init(defaultValue: .light, propagation: .environment)
}
}A write on any ancestor is visible to all descendants. Reading walks up the hierarchy to the nearest ancestor that has set the value, returning defaultValue if none has:
// Parent sets the theme for its entire subtree:
parentModel.node.context.colorScheme = .dark
// Any descendant reads it (returns .dark — inherited from parent):
let scheme = childModel.node.context.colorScheme
// A child can locally override it; only that child and its descendants see the override:
childModel.node.context.colorScheme = .lightTo remove a local override and go back to inheriting from the nearest ancestor:
node.removeContext(\.colorScheme)Declare a preference key by extending PreferenceKeys with a computed property that returns a PreferenceStorage descriptor. The descriptor includes a reduce closure that folds contributions together:
extension PreferenceKeys {
var totalCount: PreferenceStorage<Int> {
.init(defaultValue: 0) { $0 += $1 }
}
var hasUnsavedChanges: PreferenceStorage<Bool> {
.init(defaultValue: false) { $0 = $0 || $1 }
}
}Each node writes its own contribution:
node.preference.totalCount = 3Any ancestor reads the aggregate of the whole subtree (self + all descendants):
let total = parentNode.preference.totalCount // sum of all contributions in subtreeTo remove a node's contribution:
node.removePreference(\.totalCount)Both reads and writes participate in SwiftModel's observation system: wrapping a read in Observed { ... } creates a stream that re-fires whenever any contributing node changes, or when nodes are added or removed from the subtree.
Propagating a colour scheme / theme:
extension ContextKeys {
var theme: ContextStorage<AppTheme> {
.init(defaultValue: .default, propagation: .environment)
}
}
// Root model sets the theme once:
func onActivate() {
node.context.theme = userPreferences.theme
}
// Any descendant reads it:
let colors = node.context.theme.colorsAggregating unsaved-changes across a subtree:
extension PreferenceKeys {
var hasUnsavedChanges: PreferenceStorage<Bool> {
.init(defaultValue: false) { $0 = $0 || $1 }
}
}
// Each editor signals its own dirty state:
func onActivate() {
node.forEach(Observed { isDirty }) { dirty in
node.preference.hasUnsavedChanges = dirty
}
}
// The document root reads the aggregate:
var showsUnsavedIndicator: Bool {
node.preference.hasUnsavedChanges
}It is common that models need to communicate up or down the model hierarchy. Often it is most natural to set up a callback closure for children to communicate back to parents, or for parents to call methods directly on children. But for more complicated setups, SwiftModel also supports sending events up and down the model hierarchy.
enum AppEvent {
case logout
}
func onLogoutTapped() { // ChildModel
node.send(AppEvent.logout)
}
func onActivate() { // AppModel
node.forEach(node.event(of: AppEvent.logout)) {
user = nil
}
}By default an event is sent to the sending model itself and any of its ancestors, but you can override that behavior by providing a custom receivers list.
node.send(AppEvent.userWasLoggedOut, to: .descendants)Often events are specific to one type of model, and SwiftModel adds special support for Model's using their Event extension point.
@Model struct StandupDetail {
enum Event {
case deleteStandup
case startMeeting
}
func deleteButtonTapped() {
node.send(.deleteStandup)
}
}Now you can explicitly ask for events from composed models, and you will also receive an instance of the sending model.
node.forEach(node.event(fromType: StandupDetail.self)) { event, standupDetail in
switch event {
case .deleteStandup: ...
case .startMeeting: ...
}
}Because SwiftModel owns and tracks all of a model's state, events, and async tasks, it can make tests exhaustive by default: any side effect you didn't explicitly assert causes the test to fail. This catches regressions that are invisible to ordinary unit tests.
Replace withAnchor() with andTester() to get a ModelTester alongside the live model. You can override dependencies in the same call:
@Test func testAddCounter() async {
let (model, tester) = AppModel().andTester {
$0.factClient.fetch = { "\($0) is a good number." }
}
model.addButtonTapped()
await tester.assert {
model.counters.count == 1
}
}Assertions must
awaitbecause state and event propagation is asynchronous.
The assert builder block accepts any number of predicates. Using == gives you a pretty-printed diff on failure; any other Bool expression also works:
await tester.assert {
model.count == 42 // diff on failure
model.isLoading == false // diff on failure
model.title.hasPrefix("A") // plain bool — no diff
}Use unwrap to wait for an optional child model to appear before interacting with it:
let row = try await tester.unwrap(model.counters.first)
row.counter.incrementTapped()
await tester.assert {
row.counter.count == 1
}Pass a TestProbe wherever the model expects a callback closure. Call tester.install(probe) to opt into exhaustion checking for it:
@Test func testFactButtonTapped() async {
let onFact = TestProbe()
let (model, tester) = CounterModel(count: 2, onFact: onFact.call).andTester {
$0.factClient.fetch = { "\($0) is a good number." }
}
tester.install(onFact)
model.factButtonTapped()
await tester.assert {
onFact.wasCalled(with: 2, "2 is a good number.")
}
}TestProbe also supports callAsFunction, so you can write onFact: probe directly instead of onFact: probe.call.
Assert that a model sent an event using didSend(_:) inside an assert block:
@Test func testContinueWithoutRecording() async throws {
let (model, tester) = StandupDetail(standup: .mock).andTester {
$0.speechClient.authorizationStatus = { .denied }
}
model.startMeetingButtonTapped()
try await tester.unwrap(model.destination?.speechRecognitionDenied).continue()
await tester.assert {
model.destination?.speechRecognitionDenied != nil
model.didSend(.startMeeting)
}
}By default the tester enforces exhaustivity across four categories — any unasserted effect in any category fails the test when the tester is deallocated at the end of the test function:
.state— every state change must be consumed by anassertblock.events— every event sent vianode.send()must be observed withdidSend(_:).tasks— all async tasks must complete or be cancelled before the tester deallocates.probes— every installedTestProbeinvocation must be consumed bywasCalled
To focus a test on only some categories, pass exhaustivity to andTester or assign it afterwards:
// Set at creation time
let (model, tester) = MyModel().andTester(exhaustivity: .off)
let (model, tester) = MyModel().andTester(exhaustivity: [.state, .events])
// Or assign after creation
tester.exhaustivity = [.state, .events] // ignore tasks and probes
tester.exhaustivity = .off // skip all exhaustion checksWhen debugging, you can print skipped assertions without failing the test:
tester.showSkippedAssertions = true