kibinrpc

TanStack Query

First-class TanStack Query (v5) integration with type-safe query options, mutation options, and query key factories.

Installation

npm install @kibinrpc/tanstack-query @kibinrpc/client @tanstack/react-query

@kibinrpc/tanstack-query is a pure adapter — it does not pull in React itself. You can use it with any TanStack Query adapter (React, Solid, Vue, …).

Setup

Create a single module that exports the QueryClient, the RPC client, and the query proxy:

// src/kibin.ts
import { createKibinClient } from '@kibinrpc/client'
import { createKibinQuery } from '@kibinrpc/tanstack-query'
import { QueryClient } from '@tanstack/react-query'
import type { AppRouter } from './server/router'

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      retry: 1,
    },
  },
})

export const client = createKibinClient<AppRouter>({ baseUrl: '/api/rpc' })

// query.user.listUsers.queryOptions([])
// query.user.createUser.mutationOptions({ onSuccess: ... })
// query.user.queryKey() ← broad key for invalidation
export const query = createKibinQuery(client)

Wrap your app in the standard QueryClientProvider:

// src/main.tsx
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from './kibin'

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}

createKibinQuery

import { createKibinQuery } from '@kibinrpc/tanstack-query'

const query = createKibinQuery(client)

// Optional: change the query key prefix (default: '@kibinrpc')
const query = createKibinQuery(client, { queryKeyPrefix: 'myapp' })

createKibinQuery takes a KibinClient<Router> and returns a KibinQueryProxy<Router> — a mirror of your client where each method is replaced by a set of TanStack Query helpers.

Queries

queryOptions

Use queryOptions to get a fully-typed options object for useQuery, useSuspenseQuery, prefetchQuery, fetchQuery, or useQueries.

import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { query } from './kibin'

function UserList() {
  const { data, isLoading, error } = useQuery(
    query.user.listUsers.queryOptions([])
  )
  // data is inferred as User[]
}

function UserDetail({ id }: { id: string }) {
  // useSuspenseQuery — suspends until data is available
  const { data } = useSuspenseQuery(
    query.user.getUser.queryOptions([id], { staleTime: 60_000 })
  )
}

The second argument of queryOptions accepts any standard TanStack Query option except queryKey and queryFn, which are generated automatically.

Parallel queries (auto-batched)

Multiple useQuery calls in the same render are auto-batched by kibinrpc into a single HTTP request:

function Dashboard() {
  const users = useQuery(query.user.listUsers.queryOptions([]))
  const posts = useQuery(query.post.listPosts.queryOptions([]))
  // ↑ One HTTP request, two results
}

Prefetching (SSR / route loaders)

// In a route loader or server component
await queryClient.prefetchQuery(query.user.listUsers.queryOptions([]))

Mutations

mutationOptions

Use mutationOptions to get a fully-typed options object for useMutation.

import { useMutation } from '@tanstack/react-query'
import { query, queryClient } from './kibin'

function CreateUserForm() {
  const createUser = useMutation(
    query.user.createUser.mutationOptions({
      onSuccess: () =>
        queryClient.invalidateQueries({ queryKey: query.user.queryKey() }),
    })
  )

  function handleSubmit(name: string, email: string) {
    // Pass arguments as a tuple matching the server method signature
    createUser.mutate([{ name, email }])
  }

  return (
    <form onSubmit={...}>
      <button disabled={createUser.isPending}>
        {createUser.isPending ? 'Saving…' : 'Create User'}
      </button>
      {createUser.error && <p>{createUser.error.message}</p>}
    </form>
  )
}

Arguments are always passed as a tuple matching the server method's parameter list — the same tuple you'd pass directly to client.user.createUser(...).

Query Keys

Every method exposes queryKey() and every namespace exposes a namespace-level queryKey(). TanStack Query's prefix-matching means invalidating a broad key automatically covers all narrower ones beneath it.

// Namespace level — invalidates all queries in this namespace
query.user.queryKey()
// → ['@kibinrpc', 'user']

// Method level — invalidates all calls to this method regardless of args
query.user.getUser.queryKey()
// → ['@kibinrpc', 'user', 'getUser']

// Call level — invalidates exactly this call
query.user.getUser.queryKey(['user-1'])
// → ['@kibinrpc', 'user', 'getUser', { args: ['user-1'] }]

Invalidation patterns

// After creating a user, refresh the entire user namespace
queryClient.invalidateQueries({ queryKey: query.user.queryKey() })

// After updating post-1, refresh only that post
queryClient.invalidateQueries({ queryKey: query.post.getPost.queryKey(['post-1']) })

// Remove a specific query from the cache
queryClient.removeQueries({ queryKey: query.post.getPost.queryKey(['post-1']) })

// Set cached data directly (optimistic updates)
queryClient.setQueryData(
  query.user.getUser.queryKey(['user-1']),
  updatedUser
)

Full example

import { isKibinError } from '@kibinrpc/client'
import { QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query'
import { query, queryClient } from './kibin'
import type { client } from './kibin'

type User = Awaited<ReturnType<typeof client.user.listUsers>>[number]

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserSection />
    </QueryClientProvider>
  )
}

function UserSection() {
  const users = useQuery(query.user.listUsers.queryOptions([]))

  const createUser = useMutation(
    query.user.createUser.mutationOptions({
      onSuccess: () =>
        queryClient.invalidateQueries({ queryKey: query.user.queryKey() }),
    }),
  )

  return (
    <section>
      {users.isLoading && <p>Loading…</p>}

      {users.isError && (
        <p style={{ color: 'crimson' }}>
          {isKibinError(users.error)
            ? `${users.error.code}: ${users.error.message}`
            : 'Unexpected error'}
        </p>
      )}

      <ul>
        {users.data?.map((u: User) => (
          <li key={u.id}>{u.name} — {u.email}</li>
        ))}
      </ul>

      <button
        onClick={() => createUser.mutate([{ name: 'Alice', email: 'alice@example.com' }])}
        disabled={createUser.isPending}
      >
        {createUser.isPending ? 'Adding…' : 'Add User'}
      </button>

      {createUser.error && (
        <p style={{ color: 'crimson' }}>
          {isKibinError(createUser.error)
            ? `${createUser.error.code}: ${createUser.error.message}`
            : 'Unexpected error'}
        </p>
      )}
    </section>
  )
}

Error handling

All query and mutation errors are typed as KibinError. Use isKibinError from @kibinrpc/client to narrow the type and access code and message:

import { isKibinError } from '@kibinrpc/client'

if (isKibinError(error)) {
  console.log(error.code)    // e.g. 'NOT_FOUND', 'TIMEOUT'
  console.log(error.message) // human-readable description
}

See Error Handling for the full list of built-in codes.

API reference

createKibinQuery(client, config?)

ParameterTypeDescription
clientKibinClient<Router>The kibinrpc client to wrap
config.queryKeyPrefixstringPrefix for all generated query keys. Default: '@kibinrpc'

Returns a KibinQueryProxy<Router>.

KibinQueryProxy<Router>

For every namespace NS and method M on the client:

PropertySignatureDescription
query[NS][M].queryOptions(args, options?) => QueryObserverOptionsOptions for useQuery, useSuspenseQuery, prefetchQuery, …
query[NS][M].queryKey(args?) => readonly unknown[]Stable key for this method. Pass args for a call-level key, omit for a method-level key
query[NS][M].mutationOptions(options?) => MutationObserverOptionsOptions for useMutation
query[NS][M].mutationKey() => readonly unknown[]Stable key for the devtools
query[NS].queryKey() => readonly unknown[]Namespace-level key for broad invalidation

Known limitations

AbortSignal from TanStack Query is not forwarded. TanStack Query passes a signal to queryFn to support query cancellation. kibinrpc does not currently accept a per-call signal, so cancelling a query from TQ's side (e.g. unmounting a component) does not abort the underlying HTTP request.

As a workaround, override queryFn manually or create a dedicated client configured with signal:

query.user.listUsers.queryOptions([], {
  queryFn: ({ signal }) =>
    createKibinClient<AppRouter>({ baseUrl: '/api/rpc', signal }).user.listUsers(),
})

On this page