Read this document when you want the UI side of SwiftSync to feel simple.
Short version:
- use
@SyncQueryfor reactive local lists - use
@SyncModelfor one reactive local row by ID - use
SyncQueryPublisher/SyncModelPublisherwhen you are not in SwiftUI - keep the network and mutation logic outside the view; let the UI react to local store updates
SwiftUI is the primary integration path. UIKit is supported via SyncQueryPublisher when SwiftUI is not available.
This doc explains:
- what to use first
- the common query shapes
- how to structure save flows so the UI stays simple
- the rationale behind the current reactive-read model
Pick the smallest tool that matches the screen:
@SyncQuery: a reactive local list@SyncModel: one reactive local row by sync IDSyncQueryPublisher: a non-SwiftUI reactive listSyncModelPublisher: a non-SwiftUI reactive single-row read
They do not call the network by themselves.
Think of SwiftSync reads like this:
- sync writes data into the local store
- the UI reads from the local store
- reactive read helpers keep the UI fresh when relevant local data changes
In practice, @SyncQuery means:
- "Keep this list in sync with local storage, using this filter and sort order."
Use one of these three patterns first:
relationship:+relationshipID:For relationship-scoped screens, like tasks for a project.predicate:For scalar-only filters or compound business filters.- plain fetch with
sortBy:For screens that show all rows of a model type.
@SyncQuerykeeps an array updated with rows from the localSyncContainerthat match a rule.@SyncModelkeeps one local model (looked up by sync ID) refreshed for UI use.
Three common query shapes:
relationship:+relationshipID:(relationship-scoped query by ID)
- Example: all
Taskrows that belong to a specificProjectID.
@SyncQuery(
Task.self,
relationship: \Task.project,
relationshipID: projectID,
in: syncContainer,
sortBy: [SortDescriptor(\Task.updatedAt, order: .reverse)]
)
var tasks: [Task]predicate:(custom business filters)
- Use for scalar-only filters, compound filters, or non-relationship business filters.
- plain fetch (optionally sorted)
- Use when the screen needs all rows of a model type and only local ordering/filter defaults.
SwiftSync requires an explicit relationship key path for relationship-scoped queries.
- pass
relationship:for every relationship-scoped query - to-one and to-many are both supported via typed key paths
Example (ambiguous relationship):
@SyncQuery(
Ticket.self,
relationship: \Ticket.assignee,
relationshipID: userID,
in: syncContainer,
sortBy: [SortDescriptor(\Ticket.updatedAt, order: .reverse)]
)
var assignedTickets: [Ticket]Example (same queried model + same related model, multiple paths):
Task.assignee(User?)Task.reviewer(User?)Task.watchers([User])@SyncQuery(Task.self, relationship: ..., relationshipID: userID, ...)stays explicit for each path.
Related modeling note:
- relationship-scoped queries assume the local relationship graph is trustworthy
- for many-to-many relationships, ensure the pair has one explicit inverse anchor (
@Relationship(inverse: ...)) and seedocs/project/relationship-integrity.mdfor the corrected rule
sortBy:defines order.refreshOn:expands which related model changes should invalidate/refetch the query.sortBy:does not change invalidation scope.
Example:
@SyncQuery(
Task.self,
relationship: \Task.assignee,
relationshipID: userID,
in: syncContainer,
sortBy: [
SortDescriptor(\Task.priority, order: .reverse),
SortDescriptor(\Task.updatedAt, order: .reverse)
],
refreshOn: [\.project]
)
var tasks: [Task]Mental model for refreshOn: [\.project]:
- "Also refresh this query if a task's related project changes, because the UI reads project data."
High level flow:
- Sync writes happen in a background context.
SyncContainerobserves saves and tracks changed IDs/types.@SyncQuery/@SyncModelinvalidate and refetch when relevant changes happen.- SwiftUI re-renders using fresh local snapshots.
This is the "sync and forget" experience: sync updates local storage, and reactive reads update UI.
Use these rules if you want SwiftSync to stay predictable as screens get more complex.
Treat SwiftUI views as reactive readers of local state.
- views read from
@SyncQuery/@SyncModel - views render UI and collect user intent
- views should not own persistence or sync orchestration logic
Put persistence + sync behavior in a domain/service layer (for example, a syncEngine).
- domain layer performs backend mutations
- domain layer syncs refreshed backend data into local storage
- views update automatically from local reactive reads
Default rule for navigation destinations, sheets, and modals:
- pass scalar IDs and simple values (
String, enums, booleans, etc.) - child view queries/owns the models it needs
- avoid passing SwiftData model objects across view boundaries
Why:
- it keeps data ownership local to the view
- it avoids stale retained model-reference assumptions
- it makes modal/detail flows easier to reason about and test
Render-only leaf subviews may take scalar display values derived by the parent (title, status, count, etc.) instead of full models.
Recommended flow for edit sheets/modals:
- Detail view presents modal and passes IDs/scalars.
- Modal owns form draft state and submits "save" intent.
- Domain layer performs backend mutation + targeted sync.
- Detail view re-renders from local store changes.
Practical rule:
- modal initiates save intent
- domain layer performs save/sync
- detail view reacts; it does not manually re-fetch the backend
UI should refresh from the local store, not directly from the backend response path in the view.
- local SwiftData store is the UI read source of truth
- backend remains authoritative for mutation confirmation
- domain layer decides sync strategy (targeted re-fetch, response-driven sync, optimistic write + reconciliation)
The key invariant is stable: views read local reactive state; the domain layer keeps that local state current.
If you are not using SwiftUI, use the Observation-based publishers instead of trying to recreate your own reactive bridge:
SyncQueryPublisherfor lists via reactiverowsSyncModelPublisherfor a single row via reactiverow
import Observation
final class ProjectsViewController: UIViewController {
private var projectsObserver: SyncQueryPublisher<Project>?
func bindProjects() {
let observer = SyncQueryPublisher(
Project.self,
in: syncContainer,
sortBy: [SortDescriptor(\Project.name)]
)
projectsObserver = observer
func track() {
withObservationTracking {
applySnapshot(observer.rows)
} onChange: {
Task { @MainActor in track() }
}
}
track()
}
}Single-row detail/state-machine example:
final class TaskDetailMachine {
private let taskPublisher: SyncModelPublisher<Task>
init(taskID: String, syncContainer: SyncContainer) {
self.taskPublisher = SyncModelPublisher(
Task.self,
id: taskID,
in: syncContainer
)
}
}Recommended machine shape for synced detail screens:
- let the screen machine own the reactive publishers and load/submission orchestration
- expose live reads from those publishers through thin computed properties on the machine
- keep lightweight display-specific derivations there when they are part of the screen contract, such as sorted reviewer or watcher names
- do not retain a separate same-identity snapshot of the synced model inside the machine just to reshape it for the view
Why this shape works:
- the machine boundary stays consistent with the rest of the app
- the view can remain mostly declarative without taking on sync orchestration
- the machine avoids introducing another cache layer that can go stale across same-identity updates
Applied to a task-detail style screen, prefer this shape:
@MainActor
@Observable
final class TaskDetailMachine {
private let taskPublisher: SyncModelPublisher<Task>
private let itemPublisher: SyncQueryPublisher<Item>
private let loadMachine: ScreenLoadMachine
var task: Task? { taskPublisher.row }
var items: [Item] { itemPublisher.rows }
var reviewerNames: [String] { task?.reviewers.map(\.displayName).sorted() ?? [] }
}Avoid this shape:
- storing a separate
TaskDetailViewStateor similar retained snapshot that mirrorstaskPublisher.row - copying publisher output into another long-lived same-identity value unless that extra state represents a real UI contract that cannot be derived safely on read
SyncQueryPublishersupports the same query shapes as@SyncQuery: - plain fetch with optional predicate
relationship:+relationshipID:for relationship-scoped queries
SyncModelPublisher matches the single-row contract of @SyncModel: observe one row by sync identity and rebind when that row changes.
Both publishers react to the same internal save notifications as @SyncQuery / @SyncModel and reload from the local store after relevant sync-driven save notifications.
Hold it as a property — it starts observing on init and stops on deinit.
This section keeps the key design reasoning in one place so users and maintainers can understand why the reactive APIs look the way they do.
Provide a practical "sync and forget" experience for SwiftUI apps:
- background sync writes local data
- UI updates automatically from local reads
- minimal app-level invalidation plumbing
SyncContainerwith main/background context separation- save observation and changed-ID processing
- reliable background sync execution/cancellation behavior
- fresh main-context fetches can see background writes
- long-lived retained model references can still become stale
Implication:
- reactive UI should be query-snapshot driven, not retained-object-reference driven
SwiftData gives us useful primitives (fetch, save, save notifications, changed identifiers), but not a full FRC-style list-change contract.
What it does not give us here:
- automatic in-place refresh of all retained model references across contexts
- an FRC-style granular insert/update/delete callback contract
- ordered to-many sync semantics needed by this pipeline
So SwiftSync favors query-driven reactive reads instead of trying to recreate FRC behavior.
Principle:
- UI renders from query snapshots (
@SyncQuery,@SyncModel) - sync writes trigger invalidation/refetch via
SyncContainer
Pros:
- smallest custom infrastructure
- aligns with SwiftUI + SwiftData
- easy to reason about and document
Tradeoffs:
- not object-reference live-merge semantics
- refetch precision depends on invalidation heuristics and
refreshOn
Principle:
- internal query registry owns descriptors + cached snapshots and invalidation
Pros:
- more targeted invalidation
- may scale better for larger apps
Tradeoffs:
- more framework complexity and maintenance
Pros:
- closest to legacy FRC behavior
- richer non-SwiftUI callback possibilities
Tradeoffs:
- highest complexity
- easy to introduce subtle bugs
- duplicates work SwiftUI already handles with identity-based list diffs
It gives the best balance of:
- reliability
- API simplicity
- maintainability
- alignment with SwiftUI
Core convention:
- treat query wrappers as the UI source of truth
- avoid relying on retained model instances staying fresh automatically
- SwiftSync does not try to provide FRC-style granular diff callbacks.
- Reactive read wrappers are the primary intended SwiftUI integration path.
- Ordered to-many sync semantics are not part of this reactive read design.
Revisit the architecture if we repeatedly see:
- performance issues from broad refetches
- invalidation precision problems affecting UX
- real demand for non-SwiftUI granular change callbacks
- Should
SyncContainerexpose a public publisher/stream for changed IDs/types, or keep invalidation wrapper-internal? - Should we expose a stricter detail-view refresh mode as an opt-in?
- What metrics/logging would best reveal over-refetching before adding a query registry?