Prefetching and hydration
For the best user experience, prefetch data in Server Components and pass it to the client via HydrationBoundary. The client component receives cached data instantly with no loading state.
The proxy caller in lib/query.ts cannot run on the server: there is no page there to resolve its relative proxyPath against. The server has the API definition, so it prefetches with a direct caller built from it. Name the definition after the proxy's source — defineApi({ name: "api", ... }) for createProxyQueryCaller<typeof api>("api") — so that both callers build the same keys:
import { createQueryCaller } from "@noverax/courier-react"
import { api } from "@/api"
export const qServer = createQueryCaller(api)// app/users/page.tsx — React Server Component
import { dehydrate, HydrationBoundary, QueryClient } from "@noverax/courier-react"
import { qServer } from "@/lib/query.server"
import { UserList } from "./user-list"
export default async function UsersPage() {
const queryClient = new QueryClient()
// Prefetch using the built-in prefetchQuery
await qServer.users.prefetchQuery(queryClient, "getAll")
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList />
</HydrationBoundary>
)
}// app/users/user-list.tsx
"use client"
import { q } from "@/lib/query"
export function UserList() {
// This picks up the prefetched data instantly — no loading state
const { data } = q.users.useQuery("getAll")
return (
<ul>
{data?.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}Query key must match
prefetchQuery automatically generates the same hierarchical key as useQuery. For q.users.useQuery("getAll"), the key is ["api", "users", "getAll"]. With variables, include them as the fourth element. The first element is the source name: the proxy caller's first argument, and the direct caller's defineApi({ name }) — derived from the baseURL host when there is none, which is why the definition should be named.
Prefetching with variables
import { dehydrate, HydrationBoundary, QueryClient } from "@noverax/courier-react"
import { qServer } from "@/lib/query.server"
import { UserProfile } from "./user-profile"
export default async function UserPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient()
await qServer.users.prefetchQuery(queryClient, "getById", {
variables: { id: Number(params.id) },
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserProfile userId={Number(params.id)} />
</HydrationBoundary>
)
}Cache invalidation patterns
React Query uses partial key matching for invalidation. Hierarchical keys enable invalidation at different levels:
import { useQueryClient } from "@noverax/courier-react"
const queryClient = useQueryClient()
// Invalidate a specific query with exact variables
queryClient.invalidateQueries({
queryKey: ["api", "users", "getById", { id: 5 }],
})
// Invalidate all queries for a specific method (any variables)
queryClient.invalidateQueries({
queryKey: ["api", "users", "getAll"],
})
// Invalidate all queries for a group
queryClient.invalidateQueries({
queryKey: ["api", "users"],
})
// Invalidate every query for a source
queryClient.invalidateQueries({
queryKey: ["api"],
})
// Invalidate everything
queryClient.invalidateQueries()Dependent queries
When one query depends on another's result, use the enabled option to create a chain. The second query only fires after the first succeeds:
"use client"
import { q } from "@/lib/query"
export function UserPosts({ userId }: { userId: number }) {
// First query: fetch the user
const { data: user } = q.users.useQuery("getById", {
variables: { id: userId },
})
// Second query: only runs after the first succeeds
const { data: posts, isLoading } = q.posts.useQuery("getByAuthor", {
variables: { authorId: user?.id! },
enabled: !!user?.id,
})
if (isLoading) return <p>Loading posts...</p>
return (
<div>
<h2>Posts by {user?.name}</h2>
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}Parallel queries
Multiple useQuery calls in the same component run in parallel automatically. React Query manages all the loading and error states independently:
"use client"
import { q } from "@/lib/query"
export function Dashboard() {
// These queries run in parallel automatically
const { data: users, isLoading: usersLoading } = q.users.useQuery("getAll")
const { data: posts, isLoading: postsLoading } = q.posts.useQuery("getAll")
const { data: stats, isLoading: statsLoading } = q.analytics.useQuery("summary")
const isLoading = usersLoading || postsLoading || statsLoading
if (isLoading) return <p>Loading dashboard...</p>
return (
<div>
<h2>Users: {users?.total}</h2>
<h2>Posts: {posts?.total}</h2>
<h2>Views: {stats?.totalViews}</h2>
</div>
)
}Infinite queries
For paginated lists with a "load more" pattern, use useInfiniteQuery. It wraps React Query's useInfiniteQuery with the same proxy transport and type safety:
"use client"
import { q } from "@/lib/query"
type UserList = { data: User[]; total: number; nextPage: number | null }
export function InfiniteUserList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
q.users.useInfiniteQuery("getAll", {
variables: (page) => ({ page }),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
})
return (
<div>
{data?.pages.flatMap((page) =>
page.data.map((user) => <div key={user.id}>{user.name}</div>)
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
)
}variables is called with each page's parameter, so the parameter reaches the request. The first page's variables are part of the cache key — ["api", "users", "getAll", { page: 1 }, "infinite"] here — so variables that select a different list, such as a filter, start it again from the first page.
Built-in infinite queries
useInfiniteQuery is accessed through the same dot-notation API — no need to import from @tanstack/react-query or wire up the caller manually. It uses the same proxy transport and shares the same defaults as useQuery and useMutation.
Next steps
- Middleware — composable middleware for request interception, logging, and error handling
- Singleton Pattern — build the query client once and import it everywhere
- createCaller — server-side alternative for RSC and Server Actions