Skip to content
jonathangaldinoPublic

About

A swift version of Query

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Kouch logo: the Swift bird lounging on an orange couch, wearing sunglasses

Kouch

Sit back on the couch. Kouch handles the caching. Fetching, caching, deduplication, background refetching, retries, garbage collection and persistence for server data in Swift and SwiftUI, so you don't have to.

CI Swift versions Platforms License: MIT

Built on the shoulders of TanStack Query. Every behavior in this package is TanStack Query's design, verified against its real implementation. Huge thanks to Tanner Linsley and the TanStack contributors: if Kouch is useful to you, star TanStack Query and sponsor TanStack. See ACKNOWLEDGEMENTS.

A behavioral port of TanStack Query (@tanstack/query-core@5.104.3) to Swift. TanStack is the specification, not code to translate: given the same scenario, Kouch behaves like TanStack Query, built with idiomatic Swift underneath (@MainActor classes, async/await, AsyncStream).

Two libraries, both iOS 17+ / macOS 14+, Swift 6 language mode:

  • Kouch: the core. No UI dependencies (no SwiftUI or UIKit).
  • KouchSwiftUI: property wrappers and environment integration on top of the core.

Status: pre-1.0 (0.1.0). The behavior is covered by 176 scenarios checked against TanStack, but the API may still change between minor versions. Developed and tested with Swift 6.4 (Xcode 27).

Installation

Swift Package Manager:

.package(url: "https://github.com/jonathangaldino/kouch.git", from: "0.1.0")

Then add "Kouch" (and "KouchSwiftUI" for the SwiftUI wrappers) to your target's dependencies.

Usage

import Kouch

let client = QueryClient()
client.mount() // react to focus / reconnect

let observer = QueryObserver(
    client: client,
    options: QueryOptions(key: ["user", 123], staleTime: .seconds(30)) { context in
        try await api.user(id: 123)
    }
)

for await result in observer.results {
    if result.isLoading { showSpinner() }
    if let user = result.data { render(user) }
}

await client.invalidateQueries(QueryFilters(queryKey: ["user"]))

With SwiftUI:

import KouchSwiftUI

@main struct MyApp: App {
    let client = QueryClient()
    var body: some Scene { WindowGroup { UserView(id: 123).queryClient(client) } } // mounts + scene-phase focus
}

struct UserView: View {
    @ObservedQuery var user: QueryObserverResult<User>
    init(id: Int) { _user = ObservedQuery(QueryOptions(key: ["user", .int(id)]) { _ in try await api.user(id: id) }) }

    var body: some View {
        if let user = user.data { Text(user.name) } else if user.isLoading { ProgressView() }
        Button("Reload") { Task { await $user.refetch() } }
    }
}

To keep the cache across launches (instant data at cold start, offline launch, paused mutations that survive the app being killed), give the client a persister. It restores before any query mounts, then saves on every change (throttled to once per second):

WindowGroup {
    RootView().queryClient(client, persister: StoragePersister()) { await client.resumePausedMutations() }
}

Persisted data must be Codable. It is stored as JSON and decoded the first time its key is read with a type. InfiniteData is not Codable, so infinite queries are not persisted.

Mutations, with an optimistic update that rolls back on failure:

struct LikeButton: View {
    let post: Post
    @ObservedMutation var like: MutationObserverResult<Int, Post, Post>

    init(post: Post, client: QueryClient) {
        self.post = post
        _like = ObservedMutation(MutationOptions(
            onMutate: { id in // runs first; its return value is the `context` below
                let previous = client.getQueryData(["post", .int(id)], as: Post.self)
                client.setQueryData(["post", .int(id)], as: Post.self) { $0?.liked() }
                return previous
            },
            onError: { _, id, previous in if let previous { client.setQueryData(["post", .int(id)], previous) } },
            onSettled: { _, _, id, _ in await client.invalidateQueries(QueryFilters(queryKey: ["post", .int(id)])) }
        ) { id in try await api.like(postID: id) })
    }

    var body: some View {
        Button("Like") { Task { try? await $like.mutate(post.id) } }.disabled(like.isPending)
    }
}

Every wrapper, and its TanStack equivalent:

Wrapper TanStack Projected value ($name)
@ObservedQuery useQuery QueryObserver: refetch()
@ObservedSelectQuery<Data, Value> useQuery({ select }) QueryObserver
@ObservedInfiniteQuery useInfiniteQuery InfiniteQueryObserver: fetchNextPage(), fetchPreviousPage()
@ObservedQueries useQueries QueriesObserver
@ObservedMutation useMutation MutationObserver: mutate(_:), reset()
@ObservedIsFetching useIsFetching
@ObservedIsMutating useIsMutating
@ObservedMutationState useMutationState

Like TanStack's React adapter, the wrappers render getOptimisticResult during the view update and subscribe / apply new options right after it, so no observable state changes mid-update.

To see all of this running, open Examples/KouchDemo: a small app on the Star Wars API with infinite lists, dependent and parallel queries, optimistic mutations, retries, polling, a streamed query, persistence and a cache inspector.

Layout

Path What
Specs/ Behavioral contracts (21 files, 176 scenarios). Each scenario has an ID, for example [dedup-001].
Conformance/tanstack/ Every scenario run against real TanStack (vitest with fake timers), including query-persist-client-core and query-async-storage-persister.
Sources/Kouch/ The core implementation.
Sources/KouchSwiftUI/ The SwiftUI adapter.
Tests/KouchTests/ Every scenario, mirrored in Swift Testing with a virtual TestClock, plus SwiftUI adapter tests (including a real NSHostingView render).
Scripts/check-parity.sh Fails if a scenario ID is missing from the specs or from either suite.
Examples/KouchDemo/ A small SwiftUI app on SWAPI showing the main features. Open KouchDemo.xcodeproj and run.

Verify

(cd Conformance/tanstack && npm install && npx vitest run)   # TanStack obeys the spec
swift test                                                  # Kouch obeys the spec
Scripts/check-parity.sh                                     # same scenarios on both sides
xcodebuild -scheme Kouch -destination 'generic/platform=iOS' build
xcodebuild -scheme KouchSwiftUI -destination 'generic/platform=iOS' build

Scope

Implemented: query keys, query state machine, Query, QueryCache, QueryClient, QueryObserver (including getOptimisticResult), stale time, garbage collection, deduplication, retries and backoff, cancellation (including abort-on-unmount when the signal is consumed), invalidation, network modes, focus refetching, mutations (Mutation, MutationCache, MutationObserver, scopes), infinite queries (InfiniteQueryObserver, fetchInfiniteQuery, maxPages), QueriesObserver with combine, dehydrate / hydrate, persistence (restore / save / subscribePersistence / persist, StoragePersister with throttling and removeOldestQuery retry, FileQueryStorage), streamed queries (streamedQuery, all three refetch modes), meta, function contexts (client, meta, mutationKey), options computed per query (StaleTime.dynamic, RefetchTrigger.dynamic, RefetchInterval.dynamic, .enabled { }, .initialData { }), retryOnMount, skipToken, throwOnError on refetches, getQueriesData / setQueriesData, mutationCache.find, mutations configured entirely through setMutationDefaults, and the SwiftUI adapter (including restore-before-mount).

Deliberately out: per-query persisters (experimental_createQueryPersister), devtools, mixed-type QueriesObserver, and what doesn't apply to Swift (suspense, fetchOptimistic, notifyOnChangeProps, structural sharing, dehydrating pending promises for server-side rendering).

Deliberate Swift differences

TanStack Kouch Why
Keys are hashed with JSON.stringify, so 1 and 1.0 are the same key QueryKeyValue.int(1) and .double(1.0) are different keys Swift typing
staleTime, refetchOn*, refetchInterval can be functions .dynamic { query in ... } cases on StaleTime, RefetchTrigger, RefetchInterval; the closure gets AnyQuery (cast to Query<Data> for typed state) One option type per tunable
enabled / initialData can be functions .enabled { query in ... } and .initialData { ... } modifiers on QueryOptions; enabled: Bool stays a plain parameter Swift binds a trailing closure to the first optional closure parameter, so queryFn must be the only one
queryFn: skipToken skipToken: true (e.g. skipToken: id == nil) Closures can't be compared, so a sentinel function doesn't work
useQuery({ throwOnError }) throws to an error boundary Not ported (no error boundaries); refetch(throwOnError:), refetchQueries(throwOnError:) and invalidateQueries(throwOnError:) are Adapter-specific in TanStack
A cancelled streamed query keeps writing chunks unless its stream read the signal The stream always stops on cancel Swift cancels the fetch's task on abort
mutationFn(variables, context) mutationFn takes (variables, context) or just (variables) Most mutation functions don't need the context
Structural sharing (replaceEqualDeep) Not needed Value types. Change detection uses Equatable when Data conforms, and otherwise a data version counter.
Tracked-props notifications (notifyOnChangeProps) A listener is notified when the derived result changes No proxies in Swift
A key can hold any type One Data type per key; a mismatch traps Type safety (see the shortcut: note in QueryCache.build)
Global onlineManager / focusManager Injected per client. OnlineManager.shared uses NWPathMonitor. FocusManager is fed by the host app Testability, and no UI dependency in core
An AbortSignal is passed to the query function context.signal (a CancellationSignal). The query function's Task is also cancelled on abort Swift cooperative cancellation
Cancelling a caller's promise does nothing Cancelling the task that awaits fetchQuery does not cancel the shared fetch either Other consumers may share that fetch
useQueries takes queries of any types QueriesObserver<Data, Value, Combined>: all entries share one type Typed results (see the shortcut: note)
combine output is deduped with replaceEqualDeep Deduped with == when Combined is Equatable, otherwise every change notifies No structural sharing
Dehydrated data is any JSON value Snapshot data is any Sendable; hydrating rebuilds each query with the data's dynamic type, and deserializeData gets the key so persisted bytes can be decoded to the right type. A query hydrated without data gets its type on first typed access Swift generics need concrete types
hydrate restores paused mutations using setMutationDefaults({ mutationFn }) Same, but the defaults must be registered with the typed setMutationDefaults(_:_: MutationOptions) overload; mutations with unregistered keys are skipped The mutation function and its types must be known to resume
Persisted data is whatever JSON.stringify makes of it Data, variables and context must be Encodable to be persisted (others are skipped). They are stored as JSON and decoded lazily with the type of the first typed read; data that no longer decodes is dropped. Errors are not persisted No runtime type information on disk
useQuery supports select @ObservedQuery<Data> has no select; use @ObservedSelectQuery<Data, Value> Lets @ObservedQuery infer its type from QueryObserverResult<Data>

Contributing

Contributions are welcome. Read CONTRIBUTING first: every behavior change starts as a spec scenario that passes against TanStack Query. Please follow the Code of Conduct, and report security issues privately (see SECURITY).

Acknowledgements

Kouch is a tribute to TanStack Query: its API, its semantics and the thought behind every edge case are theirs. Thank you, Tanner Linsley and every TanStack contributor. See ACKNOWLEDGEMENTS for details and TanStack Query's license.

License

MIT. See LICENSE. Kouch is an independent project and is not affiliated with or endorsed by TanStack.

About

A swift version of Query

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages