SwiftQuery: Declarative Data Fetching for SwiftUI
What is SwiftQuery?
SwiftQuery is a tiny, SwiftUI native data fetching and caching library inspired by TanStack Query. It gives you three property wrappers (@Query, @Mutation, and @InfiniteQuery) backed by a shared in memory cache called QueryClient. Your views simply describe what data they need, and SwiftQuery handles the rest: caching, deduplication, state management, cache invalidation, and pagination.
It is intentionally minimal: no Combine pipelines to wire up, no global singletons to subclass, no network layer of its own. Pair it with whatever HTTP stack you already use: URLSession, Alamofire, or NetworkAgent.
Why SwiftQuery Changes the Game
Traditional SwiftUI networking code follows a pattern that mixes concerns. A view model or an ObservableObject holds the data, manages loading states, handles errors, and coordinates when to fetch. This works for small apps, but as your app grows you end up duplicating the same state machine across every screen.
SwiftQuery separates the concerns cleanly:
| Aspect | Traditional Approach | SwiftQuery |
|---|---|---|
| Data fetching | Manual in view models or .task blocks |
Declarative via @Query property wrapper |
| Loading state | Custom enum or optional booleans | Built in QueryState with .stale, .fetching, .success, .error |
| Caching | Manual dictionary or NSCache | Automatic, keyed by query type + variables |
| Cache invalidation | Manual reset or notification based | Declarative via invalidating: parameter on mutations |
| Pagination | Manual page tracking, often buggy | Built in via @InfiniteQuery with automatic next page derivation |
| Optimistic updates | Complex manual rollback logic | Built in with automatic rollback on failure |
| Dependency management | ObservableObject with manual injection | Environment based QueryClient |
| Testability | Complex mocking of view models | Simple: test QueryFunc types in isolation |
Core Concepts
QueryClient
The cache lives in the SwiftUI environment. A shared default instance is provided automatically, but you can supply your own.
@main
struct MyApp: App {
@StateObject private var client = QueryClient()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.queryClient, client)
}
}
}
QueryFunc
A type that describes how to fetch a value. Conform a struct to it, optionally typing the Variables it needs.
protocol QueryFunc {
associatedtype Value
associatedtype Variables: Hashable = QueryVoid
func run(_ variables: Variables) async throws -> Value
}
MutationFunc
The write side counterpart for creating, updating, or deleting data.
protocol MutationFunc {
associatedtype Value
associatedtype Variables
func run(variables: Variables) async throws -> Value
}
Building a Screen with @Query
Here is how you would build a user profile screen. The view declares what data it needs, and SwiftQuery handles the caching and state.
import SwiftUI
import SwiftQuery
struct User: Decodable, Hashable {
let id: Int
let name: String
let email: String
}
struct FetchUser: QueryFunc {
func run(_ id: Int) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
}
struct UserView: View {
let userID: Int
@Query(FetchUser()) private var user
var body: some View {
Group {
switch user {
case .stale, .fetching:
ProgressView()
case .success(let user):
VStack {
Text(user.name).font(.title)
Text(user.email).foregroundStyle(.secondary)
}
case .error(let error):
Text("Failed: \(error.localizedDescription)")
.foregroundStyle(.red)
}
}
.task(id: userID) {
await $user.fetch(userID)
}
}
}
Notice how the view never manages loading state, never stores the user in a @State or @Published property, and never worries about caching. Everything is driven by the @Query property wrapper.
Mutations with Cache Invalidation
When you mutate data, you usually want the affected queries to refetch automatically. SwiftQuery makes this explicit.
struct CreatePost: MutationFunc {
struct Input: Encodable {
let title: String
let body: String
}
func run(variables: Input) async throws -> Post {
var request = URLRequest(url: URL(string: "https://api.example.com/posts")!)
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(variables)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(Post.self, from: data)
}
}
struct NewPostView: View {
@Mutation(CreatePost()) private var createPost
@Binding var isPresented: Bool
var body: some View {
Form {
Button("Create") {
Task {
await $createPost.mutate(
with: .init(title: "Hello", body: "World"),
invalidating: [QueryCacheKey<FetchPosts>(variables: .value)]
)
isPresented = false
}
}
}
}
}
When the mutation succeeds, SwiftQuery automatically invalidates the FetchPosts cache entry. The next time any view renders @Query(FetchPosts()), it sees a cache miss and fetches fresh data.
Optimistic Updates
For a responsive feel, you can update the cache immediately and roll back on failure.
await $createPost.mutate(
with: input,
optimistic: { client in
let posts = client.value(for: QueryCacheKey<FetchPosts>(variables: .value))
// Insert optimistically
return { /* rollback */ }
},
invalidating: [QueryCacheKey<FetchPosts>(variables: .value)]
)
SwiftQuery runs the rollback closure automatically if the mutation throws, so your UI never shows stale optimistic data.
Infinite Queries for Pagination
Paginated lists are one of the most common yet error prone patterns in iOS development. SwiftQueryâs @InfiniteQuery handles all the tedious parts.
struct FetchPosts: InfiniteQueryFunc {
let initialPageParam = 1
func run(variables: QueryVoid, pageParam: Int) async throws -> [Post] {
let url = URL(string: "https://api.example.com/posts?page=\(pageParam)")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
func nextPageParam(
lastPage: [Post],
allPages: [[Post]],
lastPageParam: Int,
allPageParams: [Int]
) -> Int? {
lastPage.isEmpty ? nil : lastPageParam + 1
}
}
struct PostsList: View {
@InfiniteQuery(FetchPosts()) private var posts
var body: some View {
List {
switch posts {
case .stale, .fetching:
ProgressView()
case .success(let data), .fetchingNextPage(let data):
ForEach(data.pages.flatMap { $0 }) { post in
Text(post.title)
}
if $posts.hasNextPage {
Button("Load more") {
Task { await $posts.fetchNextPage() }
}
}
case .error(let error):
Text(error.localizedDescription).foregroundStyle(.red)
}
}
.task { await $posts.fetch() }
}
}
The fetchingNextPage state carries the existing data, so you can keep rendering already loaded rows while the next page loads in the background.
Why SwiftQuery Belongs in Your Project
SwiftQuery is not just a caching library. It is a paradigm shift for SwiftUI apps that moves you from imperative, manually coordinated data loading to a declarative model where views declare their data dependencies and the framework takes care of the rest.
This matters most when building reusable components. A SwiftUI component that uses @Query can be dropped into any screen and it will automatically fetch whatever data it needs, cache it, and react to mutations from anywhere in the app. No wiring, no delegates, no observable objects to pass around.
The comparative table at the start of this article shows the concrete differences, but the real value is in the architecture it enables: screens become simpler, components become reusable, and data dependencies become explicit and auditable.
Recommendations
If you enjoyed this post, you might also want to read about NetworkAgent, a dependency free Swift networking library that pairs perfectly with SwiftQuery to create a complete, declarative networking stack for your SwiftUI apps.