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.

NetworkAgent: Simplify Your Swift Networking LayerTypeScript Guide 01: Introduction to TypeScript