← Back to Blog

Deep dive: framework adapters, TanStack Query, and procedure kinds

·18 min read

pyRPC started with a deliberately small TypeScript surface: createClient<Types>(), a Proxy, and one POST /rpc per call. That is still the core. What we shipped next are thin framework adapters (React, Next.js, Vue, and Svelte) that put TanStack Query on top of that same client without inventing a second transport.

This post is the architecture deep dive: package layout, why the DX stays flat, how query/mutation kinds work end-to-end, and how Next.js hydration fits.

The invariant: one transport

Every adapter ultimately calls createClient from @pyrpc/client. There is no parallel fetch layer, no links chain, no batcher in v1. That keeps the mental model identical to the vanilla client:

// Vanilla
const client = createClient<Types>({ baseUrl })
await client.greet({ name: "Ada" })

// React (same procedure name, TanStack Query around the Promise)
const api = createReactClient<Types>({ baseUrl })
api.greet.useQuery({ name: "Ada" })

If you understand the Proxy client, you understand the adapters. The hooks are glue.

Package map

pyrpc-core          @rpc / @rpc.query / @rpc.mutation + JSON-RPC
pyrpc-codegen       Types + ProcedureKinds + procedureKinds
@pyrpc/types        generated contract (npm placeholder overwritten by codegen)
@pyrpc/client       createClient<Types>(), transport
@pyrpc/react        createReactClient, TanStack Query hooks
@pyrpc/next         createNextClient, React + RSC prefetch/hydrate
@pyrpc/vue          createVueClient
@pyrpc/svelte       createSvelteClient

Dependency direction is strict: framework packages depend on @pyrpc/client, never the reverse. Next depends on React. Vue and Svelte never import React.

Why createReactClient imports ClientOptions

Types still come from @pyrpc/types, that has not changed. Adapters also accept the same runtime config as the vanilla client (baseUrl, headers), so they extend ClientOptions from @pyrpc/client and call createClient internally:

import { createClient, type ClientOptions } from "@pyrpc/client"

export type ReactClientOptions = ClientOptions & {
  kinds?: ProcedureKindMap
}

export function createReactClient<T>(options: ReactClientOptions = {}) {
  const { kinds, ...clientOptions } = options
  const client = createClient<T>(clientOptions)
  // Proxy: procedure → { useQuery, useMutation }
}

So you import types for config from the client package, and procedure contracts from @pyrpc/types. That split is intentional.

Naming: createNextClient, not createPyRPCNext

We standardized on create*Client:

  • createClient, transport
  • createReactClient / createVueClient / createSvelteClient, hooks
  • createNextClient, App Router bundle (hooks + caller + prefetch + dehydrate)

tRPC uses names like createTRPCReact / createTRPCNext. We dropped the product prefix in the factory name because the package scope (@pyrpc/next) already says whose API it is. createNextClient is the consistent pyRPC pattern.

Procedure kinds (phase 2)

TanStack Query distinguishes queries and mutations. tRPC encodes that on the server (.query() / .mutation()). pyRPC now does the same:

from pyrpc_core import rpc

@rpc.query
def get_user(user_id: int) -> dict: ...

@rpc.mutation
def update_user(user_id: int, name: str) -> dict: ...

# Bare @rpc defaults to kind "query" (backward compatible)

Introspection includes kind. Codegen emits:

export interface Types { ... }
export type ProcedureKinds = {
  get_user: "query"
  update_user: "mutation"
}
export const procedureKinds = { ... } as const satisfies ProcedureKinds

Pass kinds: procedureKinds into the adapter so TypeScript only exposes the matching hook. Without kinds (legacy), both hooks exist on every procedure, useful during migration, not the long-term default.

Providers

Wrap the app once with TanStack Query’s provider so hooks share a cache. PyRPCProvider / NextPyRPCProvider are thin convenience wrappers around that; Vue uses VueQueryPlugin; Svelte uses Svelte Query’s provider. pyRPC does not add a separate RPC or auth context, baseUrl / headers live on the factory options.

Next.js: why a bundle?

App Router splits server and client. Hooks cannot run in RSC. So createNextClient returns:

  • api, client hooks (from createReactClient)
  • createCaller, Promise client for Server Components / route handlers
  • prefetch / dehydrate / HydrateClient, RSC → client cache handoff
  • getQueryClient, request-scoped on the server (React cache), singleton in the browser

That is the full adapter, not a docs-only DIY. The pattern matches how serious App Router + React Query apps are structured, with pyRPC’s simpler client underneath.

Query keys and utils

Keys are stable and predictable: ["pyrpc", procedureName, input?]. api.useUtils() exposes invalidate / prefetch / fetch per procedure, the same job as tRPC’s utils without nested routers.

What we deliberately did not copy from tRPC

  • Links / httpBatchLink
  • Nested procedure routers on the client
  • Superjson transformers
  • Subscriptions (roadmap item; not in adapters v1)

Alignment means familiar hooks and kinds, not a feature checklist clone. pyRPC’s differentiator stays: Python @rpc → generated Types → one flat client.

Versioning the multi-package surface

npm adapters (@pyrpc/*) should stay on a synchronized version line (today 0.8.x) with peerDependencies on @pyrpc/client and TanStack Query. Python packages (pyrpc-core, adapters, codegen) can move on PyPI versions independently but must document compatible ranges when schema fields (like kind) change.

Practical rule: one feature PR that touches core kinds + codegen + JS adapters ships together; bump the npm workspace packages in lockstep for that release.

Where to go next