ehsan.blog
~/blog/api-client-part-13-tanstack-query-integration — zsh
cat api-client-part-13-tanstack-query-integration.md

The TanStack Query integration: no hand-written query keys

·4 min read

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.

bash
pnpm add @developerehsan/api-client-query @tanstack/react-query

React — the primary integration

tsx
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>
}
tsx
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.

plaintext
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

ts
import { useApiQuery, useApiMutation } from '@developerehsan/api-client-query/vue'

const { data, isLoading } = useApiQuery(api.users.get, [id])
ts
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

ts
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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.