Escaping Query: Building a Testable LiveQuery Property Wrapper
Introduction
SwiftData is great — until it isn’t.
When Apple introduced SwiftData and the @Query macro, it felt like the missing piece for persistence in SwiftUI. Clean syntax, automatic updates, no boilerplate. But there was a clear limitation baked into its design:
All persistence logic was forced to live inside views.
I already knew that persistence logic doesn’t belong in the UI layer, and with SwiftData, moving it elsewhere requires additional work that not every codebase — or developer — is willing to take on. The @Query macro itself only works inside SwiftUI views, which makes it harder to reuse persistence logic, test it in isolation, or apply it outside of SwiftUI altogether.
Recently, I watched Point-Free’s Modern Persistence series — which I highly recommend — and it inspired me to explore whether a similar approach could be applied to SwiftData.
Their approach clearly demonstrates a more advanced, flexible, and efficient way of modeling persistence, but it also relies on SQL knowledge, which not every Swift developer is familiar with or has the time to learn.
This post is about how that idea led me to build @LiveQuery — a SwiftData-backed property wrapper that works not only in SwiftUI views, but also in @Observable models and UIKit view controllers, while remaining fully testable.
Table of Contents
- Introducing @LiveQuery
- Using @LiveQuery in SwiftUI
- Using @LiveQuery Outside of SwiftUI
- Working with Multiple Model Containers
- What About Testing?
- Final Thoughts
- Source Code
Introducing @LiveQuery
@LiveQuery is a Swift property wrapper that keeps state in sync with SwiftData and refreshes automatically whenever the underlying ModelContext saves.
Conceptually, it’s an alternative to @Query, but with explicit dependency-based configuration instead of SwiftUI magic.
You can use it in:
- SwiftUI views
@Observablemodels- UIKit view controllers
- Any main-actor–isolated type
Since @LiveQuery builds directly on top of modern SwiftData APIs, it targets the same generation of platforms. The implementation is written in Swift 6 and supports iOS 17, macOS 14, watchOS 10, tvOS 17, and visionOS 2 — essentially anywhere SwiftData is available today.
As with most libraries, the first step is configuration — and in this case it’s something you want to do as early as possible.
The core idea behind @LiveQuery is that there should be a single, well-defined ModelContext responsible for reading and persisting data across the app, rather than passing that context around manually.
To make this explicit and testable, @LiveQuery is built on top of the excellent Swift Dependencies framework by Point-Free. This allows the model context to be defined globally, overridden in tests, and accessed consistently from views, observable models, or controllers.
In practice, this configuration happens at the app entry point:
import Dependencies
import SwiftData
import SwiftDataHelpers
import SwiftUI
@main
struct MyApp: App {
let container: ModelContainer = try! ModelContainer(
for: MyModel.self
)
init() {
let container = self.container
prepareDependencies {
$0.liveQueryContext.modelContext = { container.mainContext }
}
}
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(container)
}
}
}
With this setup in place, every @LiveQuery instance uses the same model container by default. This gives you a predictable, centralized persistence layer, and can still be overridden later when more advanced setups are needed.
Using @LiveQuery in SwiftUI
The easiest place to start using @LiveQuery is inside a SwiftUI view.
If you’re already familiar with SwiftData’s @Query macro, the syntax should feel immediately recognizable.
Here’s what it looks like in practice:
import SwiftDataHelpers
import SwiftUI
struct PeopleList: View {
@LiveQuery(
predicate: #Predicate<Person> { $0.isActive },
sort: [SortDescriptor(\Person.name)]
)
private var people: [Person]
var body: some View {
List(people) { person in
Text(person.name)
}
}
}
At a glance, this isn’t very different from @Query. You still describe what you want — a predicate and sort order — and SwiftData keeps the collection up to date as changes are saved.
And for small, self-contained views, this setup is perfectly fine. In many cases, introducing a separate @Observable model for every view would be unnecessary overhead.
This example is intentionally a warm-up. The real value of @LiveQuery starts to show once the same property wrapper is moved out of the view and into an @Observable model, where persistence logic can be shared, tested, and reused.
Using @LiveQuery Outside of SwiftUI
As views grow in complexity, they tend to accumulate more than just layout code. Business rules, persistence logic, and state mutations start creeping in, and at that point it usually makes sense to move that logic into an @Observable model. Doing so improves separation of concerns and, just as importantly, makes the behavior much easier to test.
With @LiveQuery, that transition doesn’t require giving up live SwiftData updates.
Consider the following feature model:
import Dependencies
import Observation
import SwiftData
import SwiftDataHelpers
@MainActor
@Observable
final class PersonsFeatureModel {
@ObservationIgnored
@LiveQuery var persons: [Person]
@ObservationIgnored
@Dependency(\.liveQueryContext) var context
func addPerson(name: String, age: Int) throws {
let modelContext = try context.modelContext()
modelContext.insert(Person(name: name, age: age))
try modelContext.save()
}
}
This model owns both the queried data and the mutation logic. The persons array stays in sync with SwiftData through @LiveQuery, while write operations go through a resolved ModelContext.
It’s important to note that the ModelContext is explicitly resolved via:
@Dependency(\.liveQueryContext) var context
This is strongly recommended. Using the same dependency-backed context ensures that all SwiftData changes flow through a single, well-defined source of truth. Bypassing it and constructing or pulling a different context increases the risk of updates getting out of sync.
In SwiftUI views, it’s still perfectly fine to use:
@Environment(\.modelContext) var modelContext
because the library explicitly overrides the context at the application entry point. As long as that configuration is in place, both approaches ultimately refer to the same underlying container.
With the model in place, the view itself becomes noticeably simpler:
struct PeopleList: View {
@State private var model = PersonsFeatureModel()
var body: some View {
NavigationStack {
List(model.persons) { person in
Text(person.name)
}
}
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button("Add a random person") {
try! model.addPerson(name: "Vadims", age: 37)
}
}
}
}
}
Now the view is only responsible for presentation. When a new person is added, the persistence logic runs inside the observable model, SwiftData saves the change, and @LiveQuery automatically refreshes the collection. Even though the write happens outside the view, the list updates immediately and the new person appears on screen.
That’s the core idea behind @LiveQuery: move persistence logic where it belongs, without breaking SwiftUI’s reactive updates.
UIKit Usage
One of the goals of @LiveQuery was to avoid tying persistence logic to SwiftUI. Because it doesn’t rely on view-specific infrastructure, it works just as well in UIKit.
UIKit isn’t reactive by default, but @LiveQuery exposes its updates through the projected value of the property wrapper. Combined with the built-in LiveQueryViewController helper, this makes it straightforward to observe changes in a clean and modern way.
Here’s a minimal example:
import SwiftDataHelpers
import UIKit
final class PeopleViewController: LiveQueryViewController {
@LiveQuery(sort: [SortDescriptor(\Person.name)])
private var people: [Person]
private var displayedPeople: [Person] = []
private let tableView = UITableView(frame: .zero, style: .plain)
override func viewDidLoad() {
super.viewDidLoad()
observe($people) { [weak self] snapshot in
guard let self else { return }
displayedPeople = snapshot
tableView.reloadData()
}
}
}
The observe helper subscribes to the projected value $people and emits updated snapshots whenever SwiftData saves changes. From there, you can update your UIKit views however you see fit.
Even though UIKit remains imperative, the underlying data flow stays consistent. Whenever SwiftData persists a change, @LiveQuery propagates a new snapshot, and the view controller reacts accordingly.
This keeps persistence logic decoupled from UI frameworks, while still allowing UIKit-based screens to benefit from live SwiftData updates — without additional boilerplate
Working with Multiple Model Containers
As apps grow, a single database is not always enough. In more advanced setups, you might end up working with multiple SwiftData stores — for example, one container for app-wide data and another for private or feature-specific state that should remain isolated.
@LiveQuery is designed to handle these cases without leaking persistence details into your views.
For situations like this, the library provides a small SwiftUI helper called LiveQueryBindable. It allows you to scope queries to a specific ModelContainer in a very explicit way.
LiveQueryBindable(modelContainer: .privatePersons) {
PrivatePeopleView()
}
Under the hood, this helper overrides the liveQueryContext dependency for the lifetime of the view hierarchy it wraps. At the same time, it also overrides SwiftUI’s .modelContainer, ensuring that both @LiveQuery and any SwiftUI views inside the closure resolve their data from the same container.
As a result, everything within that scope — views, observable models, and queries — transparently uses a separate SwiftData store.
From the view’s perspective, nothing changes:
struct PrivatePeopleList: View {
@LiveQuery(sort: [.init(\.name)])
private var persons: [Person]
var body: some View {
List(persons) { person in
Text(person.name)
}
}
}
Even though PrivatePeopleList looks identical to any other SwiftUI view using @LiveQuery, it now reads from and reacts to a different ModelContainer. Data from the public store and the private store remain fully isolated, and there’s no ambiguity about which database a particular feature is using.
This approach keeps advanced persistence setups explicit, predictable, and easy to reason about — without introducing hidden environment magic or special-case logic in individual views.
SwiftUI Previews
Supporting SwiftUI previews was a deliberate part of the design. Since @LiveQuery resolves its ModelContext through explicit dependencies, it becomes straightforward to substitute a persistent store with an in-memory one whenever the app is not running in a live environment.
This makes previews a first-class workflow. Instead of writing to a real database or adding preview-only code paths, the same data access layer can be reused by simply changing how the ModelContainer is configured.
There are multiple ways to approach this, but in the example app shipped with the library, previews and tests rely on the same mechanism: selecting the underlying ModelContainer based on execution context.
The idea is simple. When the app is running in a live context — meaning on a simulator or a real device — SwiftData uses persistent on-disk stores. In every other case, such as previews or tests, the container falls back to an in-memory configuration.
Here’s how that looks in practice:
extension ModelContainer {
static let main: ModelContainer = {
@Dependency(\.context) var context
if context == .live {
return try! ModelContainer(for: Schema([Person.self, Pet.self]))
}
return makeTestContainer(name: "main")
}()
static let privatePersons: ModelContainer = {
@Dependency(\.context) var context
if context == .live {
return try! ModelContainer(
for: Schema([Person.self, Pet.self]),
configurations: .init(
url: .documentsDirectory
.appendingPathComponent("private-persons.sqlite")
)
)
}
return makeTestContainer(name: "private")
}()
private static func makeTestContainer(name: String) -> ModelContainer {
try! ModelContainer(
for: Schema([Person.self, Pet.self]),
configurations: .init(name, isStoredInMemoryOnly: true)
)
}
}
With this setup, any time the app is not running in a live context, SwiftData automatically switches to an in-memory store. This applies equally to SwiftUI previews and unit tests.
Previews with @LiveQuery
Because @LiveQuery resolves its ModelContext from dependencies, each preview must explicitly provide one. Without this configuration, the preview will fail at runtime, since the dependency would be missing.
A basic preview using the shared main container looks like this:
#Preview {
prepareDependencies {
$0.liveQueryContext.modelContext = {
ModelContainer.main.mainContext
}
}
return PersonsView()
.modelContainer(.main)
}
In this case, the preview uses the same main container definition as the app itself — which automatically resolves to an in-memory store when not running live.
Custom Containers per Preview
For more focused previews, you can also create a fully custom ModelContainer and scope it to a single preview:
#Preview {
let container = try! ModelContainer(
for: Schema([Person.self, Pet.self]),
configurations: .init(isStoredInMemoryOnly: true)
)
prepareDependencies {
$0.liveQueryContext.modelContext = {
container.mainContext
}
}
return PersonsView()
.modelContainer(container)
}
Both approaches are designed to support fast iteration. Whether you reuse a shared container or create a custom one per preview, SwiftUI previews run entirely against in-memory stores, without writing to disk or affecting real application data.
The difference is mostly about scope. Using a shared container mirrors the app’s structure more closely, while a custom container per preview gives you tighter isolation when focusing on a specific view or feature.
In both cases, the feedback loop stays short: you can experiment freely, reload previews instantly, and validate changes without rebuilding the app or worrying about polluting persistent data.
What About Testing?
Testing follows the same principles shown in the previews section. Because @LiveQuery resolves its ModelContext through dependencies, tests can use in-memory SwiftData containers in exactly the same way — without touching disk or relying on SwiftUI.
To keep this post focused, I’ll cover testing in more detail in a separate article. In the meantime, the example application in the repository includes a complete test suite that demonstrates this approach in practice.
Final Thoughts
I didn’t build @LiveQuery to replace @Query. If your app is small and view-driven, Apple’s solution is great.
But once you care about architecture, testability, reuse, or UIKit support, having persistence locked inside views becomes a real limitation.
@LiveQuery is my attempt to keep the ergonomics of SwiftData while making it work in real-world codebases.
Source Code
The package is open source and available here: