Composable models for SwiftUI — struct-based, automatic async lifetime, exhaustive testing, and dependency injection from iOS 14.
@Model struct SearchModel {
var query = ""
var results: [Repo] = []
func onActivate() {
// Cancel-in-flight: each new query cancels the previous search.
// No stored Task. No [weak self]. Cancelled automatically when removed.
node.task(id: query) { query in
results = (try? await node.gitHubClient.search(query)) ?? []
}
}
}- No retain cycles, ever. Structs can't capture
self— the compiler makes retain cycles impossible, not just unlikely. - Lifetime-tied tasks.
node.taskandnode.forEachare cancelled when the model is removed. No storedTask, nodeinit, no manual cleanup. - Exhaustive tests. Any state change you didn't assert is a test failure. Refactor freely — tests check what changed, not how you got there.
- Dependency injection anywhere. Override per model, per hierarchy level, or per test — with a trailing closure at the call site.
.package(url: "https://github.com/bitofmind/swift-model", from: "0.13.0")import SwiftModel
@Model struct SearchModel {
var query = ""
var results: [Repo] = []
func onActivate() {
// Cancel-in-flight. With a plain @Observable class you'd need a stored
// Task, [weak self] in every closure, and a deinit for cleanup. Here it's one line.
node.task(id: query) { query in
results = (try? await node.gitHubClient.search(query)) ?? []
}
}
}@Model gives the struct observable storage, a node interface for async work and dependencies, and everything needed to participate in the model hierarchy. node.task(id:) watches any value expression and restarts the async task whenever it changes — cancelling the previous in-flight task first. Under the hood it uses Observed { query }, which tracks any Swift value expression and emits whenever its result changes — not just simple properties. (Apple added a similar Observations type in iOS 26; Observed works from iOS 14.)
node.gitHubClient accesses the GitHubClient dependency via a DependencyValues keypath — the same keypath used to override it in tests and previews, with no change to the model itself.
import SwiftUI
struct SearchView: View {
@ObservedModel var model: SearchModel
var body: some View {
List(model.results) { repo in Text(repo.name) }
.searchable(text: $model.query)
}
}
@main struct SearchApp: App {
let model = SearchModel().withAnchor()
var body: some Scene {
WindowGroup { SearchView(model: model) }
}
}withAnchor() activates the model hierarchy — starting onActivate tasks, wiring up dependencies, and registering it for observation. It returns the same model, so it composes naturally.
No test harness setup required. Override dependencies at the call site, drive the model, assert the final state — any unasserted change is a failure:
import Testing
import SwiftModel
@Test(.modelTesting) func testSearch() async {
let model = SearchModel().withAnchor {
$0.gitHubClient.search = { _ in Repo.mocks }
}
model.query = "swift"
await expect(!model.results.isEmpty)
}expect { } waits for all predicates to become true, settles async work, and fails if the model changed anything you didn't assert.
Compare to TCA, where tests encode the full action sequence:
// TCA
await store.send(.factButtonTapped) { $0.isLoading = true }
await store.receive(\.factResponse) { $0.fact = "42 is a great number" }
// SwiftModel
model.factButtonTapped()
await expect { model.fact == "42 is a great number" }Rename a method or split an async effect — the test keeps passing as long as the outcome is the same. See Testing for the full comparison.
@Observable handles reactive state well. It leaves the rest to you. Here's the same model written with @Observable:
@Observable class SearchModel {
var query = "" { didSet { scheduleSearch() } }
var results: [Repo] = []
private var searchTask: Task<Void, Never>?
private func scheduleSearch() {
searchTask?.cancel()
searchTask = Task { [weak self] in // forget this → retain cycle
guard !Task.isCancelled, let self else { return }
self.results = (try? await GitHubClient.live.search(self.query)) ?? []
}
}
deinit { searchTask?.cancel() } // forget this → tasks outlive the view
}GitHubClient.live is hardcoded — there is no clean path to inject a test double.
@Observable class |
SwiftModel @Model |
|
|---|---|---|
| Async task lifetime | Manual Task; you manage cancellation |
node.task cancels automatically when the model is removed |
| Self references | [weak self] required in every async closure |
Not needed — and not allowed; the compiler rejects it on structs |
| Testing | No built-in harness; roll your own | expect { } exhaustively asserts state, events, and running tasks |
| Dependency injection | Global @Environment only |
Per-model overrides at any hierarchy level |
| Minimum iOS | iOS 17 | iOS 14 |
@Observable / MVVM |
TCA | SwiftModel | |
|---|---|---|---|
| Boilerplate | Low | Very high | Low |
| Retain cycles | Manual [weak self] |
Low | None — structural guarantee |
| Exhaustive testing | No | Yes (action-ordered) | Yes (state-focused) |
| Refactor-resilient tests¹ | — | No | Yes |
| Async lifetime | Manual Task |
Effects / Actions | node.task + auto-cancel |
| Model events | Manual callbacks | Actions | Typed streams, any direction |
| Undo / Redo | DIY | DIY | Built-in |
| Hierarchy queries | None | None | Built-in |
| Context propagation | View @Environment only |
None | Model-layer environment + preferences |
| Shared state | Manual | @Shared (value sync) |
Model dependency (live instance) |
| Thread safety | @MainActor discipline |
@MainActor discipline |
Lock-based, any thread |
| Learning curve | Minimal | Very steep | Moderate |
¹ TCA tests encode action sequences — renaming a case or splitting an effect breaks tests even when visible behaviour is unchanged. SwiftModel tests assert final state only.
Models and composition — @Model macro, child models, optional and collection composition, @ModelContainer for navigation enums and reusable wrappers.
Async lifetime — node.task, node.task(id:) for restart-on-change, node.onChange(of:) for old/new value transitions, node.forEach, reactive streams with Observed, onActivate, withActivation for composable behaviour injection, observeAnyModification, transactions, and cancellation groups.
Undo and redo — node.trackUndo() with selective key-path tracking, UndoManager integration, and observable canUndo / canRedo.
Dependency injection — @ModelDependency, per-model and per-hierarchy overrides, preview values, and test overrides at the anchor site.
Navigation — modal sheets, navigation stacks, and deep links driven by model state. No extra libraries required.
Events — typed events that travel up or down the model hierarchy. Composable with model-scoped Event types.
Hierarchy and preferences — mapHierarchy for tree queries, bottom-up preference aggregation, top-down environment propagation, and local node storage.
Testing — expect { }, settle(), require(), TestProbe, exhaustivity control per category, time-control with TestClock, and withModelTesting for non-trait contexts.
Debugging — withDebug(), diff styles, trigger tracing, and DebugOptions for memoize and Observed.
| Example | What it shows |
|---|---|
| CounterFact | Nested models, async effects with error handling, dependency injection |
| Search | Cancel-in-flight search, per-item async loading, TestProbe, withActivation in previews and tests |
| Onboarding | 3-step sign-up wizard: @ModelContainer enum navigation, node.task(id:) for async username availability check, node.local, node.task with catch: |
| TodoList | Undo/redo with selective tracking, preference aggregation (bottom-up), environment propagation (top-down), targeted debug with Observed(debug:) |
| Standups | Complete app: navigation, timers, speech recognition, persistence, exhaustive tests |
Clone the repo and open any example in Xcode to run it immediately.
Not a UI framework. SwiftModel sits entirely in the model layer. Views are plain SwiftUI.
Not an opinion on file structure. One model per file or many — organise however suits your team.
Not a Combine replacement. SwiftModel uses async/await throughout. Combine is supported via node.onReceive(_:) for projects that need it, but is not required.
Not magic. The @Model macro is a code generator. Expand it in Xcode (Editor → Expand Macro) to see exactly what it produces. No runtime swizzling, no reflection.
SwiftModel uses swift-dependencies by Point-Free for its dependency injection system. The ideas around exhaustive testing and structured async effects were directly inspired by The Composable Architecture — SwiftModel takes a different approach, but Point-Free's work on the problem space has been invaluable.
If SwiftModel is useful to you, a star helps others find it. Issues and pull requests are welcome — this is a spare-time project, so responses may take a day or two.