pyRPC
← 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