Skip to content

API ReferencecreateProxyQueryCaller

createProxyQueryCaller

Creates a proxy-mode React Query client with dot-notation hooks for every endpoint group, routed through the server adapter.

3 min readproxy mode

Import

ts
import { createProxyQueryCaller } from "@noverax/courier-react"

Peer dependency

@noverax/courier-react requires @tanstack/react-query as a peer dependency. It re-exports QueryClient, QueryClientProvider, useQueryClient, HydrationBoundary, and dehydrate for convenience.

Signature

Accepts a source name and ProxyCallerDefaults (same as createProxyCaller). The defaults are optional for an API of plain requests; an API with streams or channels requires a boundary (or the source) among them, as createProxyCaller does. Returns a Proxy-based client with dot-notation access to hooks for each group.

ts
import type { ProxyCallerArguments } from "@noverax/courier"
import type { DefinedApi } from "@noverax/courier/define"
import type { QueryCallerClient } from "@noverax/courier-react"

declare function createProxyQueryCaller<TApi extends DefinedApi>(
  source: string,
  ...defaults: ProxyCallerArguments<TApi>
): QueryCallerClient<TApi>

Return Type

The returned object provides dot-notation access to every group defined in your API. Each group exposes:

ts
type QueryGroupClient<G> = {
  useQuery(method, options?): UseQueryResult<R, Error>
  useSuspenseQuery(method, options?): UseSuspenseQueryResult<R, Error>
  useInfiniteQuery(method, options): UseInfiniteQueryResult<R, Error>
  useMutation(method, options?): UseMutationResult<R, Error, V>
  prefetchQuery(queryClient, method, options?): Promise<void>
}

A hook's error is typed Error, as TanStack Query types it, because not every failure is an HTTP one: narrow it with isHTTPError, isValidationError or isConfigurationError from @noverax/courier. See useQuery — Error handling.

Setup

lib/query.ts
import { createProxyQueryCaller } from "@noverax/courier-react"
import type { api } from "@/api"
import { boundary } from "@/api/boundary"

export const q = createProxyQueryCaller<typeof api>("api", {
  boundary,
  proxyPath: { http: "/api/request" },  // optional, defaults to /api/request
  timeout: 10000,
  credentials: "include",
})

The defaults argument is a ProxyCallerDefaults (same shape as createProxyCaller). Notable fields:

  • proxyPath — per-transport endpoint paths. http and sse have built-in defaults; ws is mandatory for useChannel and has no default. Caller-level fields are merged per-transport over source.options.proxyPath.
  • ws — options of the socket useChannel opens: the token, the reconnect backoff, the heartbeat and the queue limits — see Connection options.
  • boundary — the result of defineBoundary: the endpoints the browser may reach and the app's proxy paths. Pass it rather than the API object — the hooks name their own transport, so nothing is read off the API, and a value import of it would put its definition in the bundle.

useChannel requires proxyPath.ws

On runtimes that cannot host WebSockets in-process (Next.js App Router, Cloudflare Workers, Vercel Edge, AWS Lambda) you must point proxyPath.ws at a standalone @noverax/courier-server. A useChannel without proxyPath.ws configured reports ConfigurationError("WS_PATH_NOT_CONFIGURED") through its error and onError when it first opens.

useQuery

Wraps @tanstack/react-query's useQuery. All standard options are supported.

components/user-list.tsx
"use client"

import { q } from "@/lib/query"

export function UserList() {
  const { data, isLoading, error } = q.users.useQuery("getAll", {
    staleTime: 60_000,
    refetchOnWindowFocus: false,
  })

  if (isLoading) return <p>Loading...</p>
  if (error) return <p>Error: {error.message}</p>

  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

Variables

When an endpoint expects variables, the variables option becomes required — enforced by TypeScript.

ts
const { data } = q.users.useQuery("getById", {
  variables: { id: "123" },
  enabled: !!id,
})

useMutation

Variables are passed to the mutate function, not to the hook.

components/create-user.tsx
"use client"

import { q } from "@/lib/query"
import { useQueryClient } from "@noverax/courier-react"

export function CreateUserForm() {
  const queryClient = useQueryClient()

  const { mutate, isPending } = q.users.useMutation("create", {
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["api", "users", "getAll"] })
    },
  })

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      const formData = new FormData(e.currentTarget)
      mutate({ name: formData.get("name") as string })
    }}>
      <input name="name" required />
      <button disabled={isPending}>
        {isPending ? "Creating..." : "Create"}
      </button>
    </form>
  )
}

Query Keys

Query keys are auto-generated with a hierarchical format: [source, group, method, variables?]:

ts
// Without variables: [source, group, method]
// With variables:    [source, group, method, variables]

q.users.useQuery("getAll")          // → ["api", "users", "getAll"]
q.users.useQuery("getById", {
  variables: { id: "123" }
})                                   // → ["api", "users", "getById", { id: "123" }]

// An infinite query adds its first page's variables and an "infinite" marker,
// so each filter gets its own list and no plain query shares its entry
q.posts.useInfiniteQuery("list", {
  variables: (cursor) => ({ cursor, tag: "news" }),
  initialPageParam: 0,
  getNextPageParam: (page) => page.nextCursor,
})                                   // → ["api", "posts", "list", { cursor: 0, tag: "news" }, "infinite"]

// Cache invalidation
queryClient.invalidateQueries({ queryKey: ["api", "users", "getAll"] })
queryClient.invalidateQueries({ queryKey: ["api", "users"] })  // entire group
queryClient.invalidateQueries({ queryKey: ["api"] })            // entire source

Per-Request Options

Use requestOptions to pass CallOptions alongside React Query options:

ts
const { data } = q.users.useQuery("getAll", {
  requestOptions: {
    timeout: 5000,
    method: "POST",  // keep this call's variables out of the address
  },
  staleTime: 30_000,
})

const { mutate } = q.files.useMutation("upload", {
  requestOptions: {
    onUploadProgress: (progress) => {
      setProgress(progress.percent)
    },
  },
})

prefetchQuery

Prefetch data in Server Components for instant hydration on the client. On the server there is no page to resolve a relative proxyPath against, so a proxy caller cannot run there unless proxyPath.http is an absolute URL. Prefetch with a direct caller built from the definition instead — the server has it — and name the definition after the proxy's source, so both build the same keys:

lib/query.server.ts
import { createQueryCaller } from "@noverax/courier-react"
import { api } from "@/api" // defineApi({ name: "api", ... })

export const qServer = createQueryCaller(api)
app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from "@noverax/courier-react"
import { qServer } from "@/lib/query.server"

export default async function UsersPage() {
  const queryClient = new QueryClient()

  await qServer.users.prefetchQuery(queryClient, "getAll")

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserList />
    </HydrationBoundary>
  )
}