The TanStack Query integration: no hand-written query keys
If you’re using TanStack Query already, you’ve probably written
useQuery({ queryKey: ['users', id], queryFn: () => fetch(...) }) more times
than you’d like to count, and you’ve probably had a query key typo cause a
cache miss that took ten minutes to spot. This post is the integration
package that removes both problems.
The problem it solves: two caches that don’t talk to each other
Problem: without an integration, you end up running two independent caching systems side by side — the pipeline’s own cache (from the caching post) and TanStack Query’s cache, each with its own keys, its own invalidation, no shared awareness. Invalidate the pipeline’s cache after a mutation, and TanStack Query’s cache still shows the stale value, because as far as it knows nothing happened.
Solution: @developerehsan/api-client-query bridges the two. Query keys
are generated automatically from your module/method descriptors — the same
descriptors the codegen CLI produces — so you’re never hand-writing
['users', id] and hoping it matches everywhere else that reads or
invalidates it.
pnpm add @developerehsan/api-client-query @tanstack/react-queryReact — the primary integration
import { useApiQuery, useApiMutation } from '@developerehsan/api-client-query/react'
function UserProfile({ id }: { id: string }) {
const { data, isLoading, error } = useApiQuery(api.users.get, [id])
if (isLoading) return <p>Loading…</p>
if (error) return <p>Couldn't load user</p>
return <p>{data.name}</p>
}function CreateUserForm() {
const mutation = useApiMutation(api.users.create, {
invalidates: [api.users.list], // auto-generates the matching query key
})
return (
<button onClick={() => mutation.mutate({ name: 'Ada', email: 'ada@x.com' })}>
Create
</button>
)
}What invalidates is actually doing: instead of you writing
queryClient.invalidateQueries({ queryKey: ['users', 'list'] }) by hand —
a string you’d have to keep in sync with however useApiQuery(api.users.list)
built its own key — you pass the method reference itself. The integration
derives the exact same key both places, from the same descriptor, so there’s
no string to typo and no manual sync to forget.
useApiQuery(api.users.list) ─┐
├──▶ same generated key, always
mutation.invalidates: [api.users.list] ─┘Real talk: the bug this actually prevents
Before I had this, I renamed a query key from ['users'] to
['users', 'list'] in one file for clarity, and forgot the invalidation
call in a different file that still referenced the old key. The mutation
“worked” — the create call succeeded — but the list didn’t refresh, and I
spent a confused ten minutes clicking refresh before realizing the keys had
drifted apart. Deriving keys from the method reference instead of a
hand-typed array makes that specific class of bug structurally impossible,
not just less likely.
Vue — the same shape, different framework conventions
import { useApiQuery, useApiMutation } from '@developerehsan/api-client-query/vue'
const { data, isLoading } = useApiQuery(api.users.get, [id])const mutation = useApiMutation(api.users.create, {
invalidates: [api.users.list],
})
mutation.mutate({ name: 'Ada', email: 'ada@x.com' })Same descriptor-based key generation underneath — the composables just
follow Vue’s reactivity conventions (data and isLoading are refs) instead
of React’s hook conventions. If you already know the React usage above, the
Vue version isn’t teaching you anything new conceptually, just syntactically.
Solid — same idea again
import { useApiQuery } from '@developerehsan/api-client-query/solid'
const [data] = useApiQuery(api.users.get, [id])I’ve used this integration far less in Solid projects than React ones — most of my production work is React/Next.js — so I’ll be honest that this is the integration I’m least battle-tested on. The key-generation mechanism is identical, but if you hit something Solid-specific that feels off, that’s genuinely useful feedback for the package, not something I’d assume is already ironed out.
Where the pipeline’s cache and TanStack’s cache each still matter
Problem people sometimes assume: “if I’m using TanStack Query, do I even need the pipeline’s own caching from post six?”
Answer: yes, and they’re doing different jobs. TanStack Query’s cache is
about UI state — what your components re-render with, request
deduplication within React’s render cycle, background refetching tied to
component lifecycle. The pipeline’s cache (and dedup, from post seven) is
about network-layer efficiency — it works the same whether you’re calling
from a React component, a Node script, or a test. If you call
api.users.get('42') directly in a script with no TanStack Query involved
at all, you still get pipeline-level caching and dedup. The integration
layers TanStack’s UI-focused caching on top, without replacing what’s
underneath.
What sticks from this post
- The integration generates query keys from your module/method descriptors, so you’re never hand-writing or manually syncing keys between a query and its invalidation.
invalidates: [api.users.list]passes the method reference itself, not a string — deriving the exact same key both places structurally.- TanStack’s cache and the pipeline’s own cache aren’t redundant — one’s about UI state and render-cycle dedup, the other’s about network-layer efficiency that works even outside a component.
- React and Vue integrations are well-worn in my own usage; Solid works the same way underneath but I’ve used it less.
Next: the SSR RPC bridge, part one — the actual problem it solves and how the architecture works, before we get into the security model in the post after.
- developerehsan