Overview
@contract-kit/react-query provides seamless integration with TanStack Query (React Query) for contract-based data fetching. It generates fully typed query and mutation options from your contracts, giving you automatic type inference, intelligent caching, and powerful data synchronization.
This package uses an options-first API that works with all TanStack Query primitives:
useQuery, useMutation, useInfiniteQuery, prefetchQuery, fetchQuery, and more.
Installation
npm install @contract-kit/react-query @tanstack/react-query
Requires TypeScript 5.0+ for proper type inference.
Setup
1. Create Your API Client
First, create a typed API client using @contract-kit/client:
import { createClient } from " @contract-kit/client " ;
export const apiClient = createClient ({
baseUrl : process.env. NEXT_PUBLIC_API_URL ?? " http://localhost:3000 " ,
headers : async () => ({
Authorization : `Bearer ${ getToken () } ` ,
}),
});
2. Create the React Query Adapter
Create a React Query adapter that wraps your API client:
import { createRQ } from " @contract-kit/react-query " ;
import { apiClient } from " ./api-client " ;
export const rq = createRQ (apiClient);
3. Set Up QueryClientProvider
Wrap your application with the QueryClientProvider:
" use client " ;
import {
isServer,
QueryClient,
QueryClientProvider,
} from " @tanstack/react-query " ;
import { ReactQueryDevtools } from " @tanstack/react-query-devtools " ;
function makeQueryClient () {
return new QueryClient ({
defaultOptions : {
queries : {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime : 60 * 1000 ,
},
},
});
}
let browserQueryClient : QueryClient | undefined ;
function getQueryClient () {
if (isServer) {
// Server: always make a new query client
return makeQueryClient ();
}
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if ( ! browserQueryClient) browserQueryClient = makeQueryClient ();
return browserQueryClient;
}
export function Providers ({ children } : { children : React . ReactNode }) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient ();
return (
< QueryClientProvider client = {queryClient}>
{children}
< ReactQueryDevtools initialIsOpen = { false } />
</ QueryClientProvider >
);
}
Usage
Basic Queries
Use queryOptions() to generate type-safe query options:
import { useQuery } from " @tanstack/react-query " ;
import { rq } from " @/lib/rq " ;
import { getTodo } from " @/contracts/todos " ;
function TodoDetail ({ id } : { id : string }) {
// Generate query options
const todoQuery = rq (getTodo). queryOptions ({
path : { id },
});
// Use with useQuery
const { data, isLoading, error, refetch } = useQuery (todoQuery);
if (isLoading) return < div >Loading...</ div >;
if (error) return < div >Error: {error.message}</ div >;
return (
< div >
< h1 >{data.title}</ h1 >
< p >Status: {data.completed ? " Done " : " Pending " }</ p >
< button onClick = {() => refetch ()}>Refresh</ button >
</ div >
);
}
Queries with Query Parameters
Pass query parameters through the query option:
import { useQuery } from " @tanstack/react-query " ;
import { rq } from " @/lib/rq " ;
import { listTodos } from " @/contracts/todos " ;
function TodoList () {
const todosQuery = rq (listTodos). queryOptions ({
query : {
limit : 50 ,
offset : 0 ,
completed : false ,
},
});
const { data, isLoading } = useQuery (todosQuery);
if (isLoading) return < div >Loading...</ div >;
return (
< ul >
{data.todos. map (( todo ) => (
< li key = {todo.id}>{todo.title}</ li >
))}
</ ul >
);
}
Mutations
Use mutationOptions() to generate type-safe mutation options:
import { useMutation, useQueryClient } from " @tanstack/react-query " ;
import { rq } from " @/lib/rq " ;
import { createTodo, listTodos } from " @/contracts/todos " ;
function CreateTodoForm () {
const queryClient = useQueryClient ();
// Generate mutation options
const createMutation = useMutation (
rq (createTodo). mutationOptions ({
onSuccess : () => {
// Invalidate todos list after creating
queryClient. invalidateQueries ({
queryKey : rq (listTodos). key (),
});
},
})
);
const handleSubmit = async ( e : React . FormEvent < HTMLFormElement >) => {
e. preventDefault ();
const formData = new FormData (e.currentTarget);
createMutation. mutate ({
body : {
title : formData. get ( " title " ) as string ,
description : formData. get ( " description " ) as string ,
},
});
};
return (
< form onSubmit = {handleSubmit}>
< input name = " title " placeholder = " Title " required />
< textarea name = " description " placeholder = " Description " />
< button type = " submit " disabled = {createMutation.isPending}>
{createMutation.isPending ? " Creating... " : " Create Todo " }
</ button >
{createMutation.error && (
< div className = " error " >{createMutation.error.message}</ div >
)}
</ form >
);
}
Optimistic Updates
Provide instant feedback with optimistic updates:
import { useMutation, useQueryClient } from " @tanstack/react-query " ;
import { rq } from " @/lib/rq " ;
import { updateTodo, listTodos } from " @/contracts/todos " ;
import type { Todo } from " @/types " ;
function TodoList () {
const queryClient = useQueryClient ();
const updateMutation = useMutation (
rq (updateTodo). mutationOptions ({
onMutate : async ( variables ) => {
// Cancel outgoing refetches
await queryClient. cancelQueries ({
queryKey : rq (listTodos). key (),
});
// Snapshot previous value
const previousTodos = queryClient. getQueryData (
rq (listTodos). key ()
);
// Optimistically update to new value
queryClient. setQueryData (
rq (listTodos). key (),
( old : { todos : Todo [] }) => ({
... old,
todos : old.todos. map (( todo ) =>
todo.id === variables.path.id
? { ... todo, ... variables.body }
: todo
),
})
);
// Return context with snapshot
return { previousTodos };
},
onError : ( err , variables , context ) => {
// Rollback on error
queryClient. setQueryData (
rq (listTodos). key (),
context?.previousTodos
);
},
onSettled : () => {
// Refetch after mutation completes
queryClient. invalidateQueries ({
queryKey : rq (listTodos). key (),
});
},
})
);
// ... rest of component
}
Prefetching Data
Prefetch data on user interaction for instant navigation:
import { useQueryClient } from " @tanstack/react-query " ;
import { rq } from " @/lib/rq " ;
import { getTodo } from " @/contracts/todos " ;
import Link from " next/link " ;
function TodoListItem ({ id , title } : { id : string ; title : string }) {
const queryClient = useQueryClient ();
const handleHover = () => {
// Prefetch on hover
const todoQuery = rq (getTodo). queryOptions ({
path : { id },
});
queryClient. prefetchQuery (todoQuery);
};
return (
< Link href = { `/todos/ ${ id } ` } onMouseEnter = {handleHover}>
{title}
</ Link >
);
}
Server-Side Data Fetching
Fetch data on the server for faster initial page loads:
import {
QueryClient,
dehydrate,
HydrationBoundary
} from " @tanstack/react-query " ;
import { rq } from " @/lib/rq " ;
import { getTodo } from " @/contracts/todos " ;
import { TodoDetail } from " @/components/TodoDetail " ;
export default async function TodoPage ({
params
} : {
params : { id : string }
}) {
const queryClient = new QueryClient ();
// Fetch on the server
const todoQuery = rq (getTodo). queryOptions ({
path : { id : params.id },
});
await queryClient. prefetchQuery (todoQuery);
return (
< HydrationBoundary state = { dehydrate (queryClient)}>
< TodoDetail id = {params.id} />
</ HydrationBoundary >
);
}
Infinite Queries
Implement pagination with infinite scrolling:
import { useInfiniteQuery } from " @tanstack/react-query " ;
import { rq } from " @/lib/rq " ;
import { listTodos } from " @/contracts/todos " ;
function InfiniteTodoList () {
const todosInfiniteQuery = rq (listTodos). infiniteQueryOptions ({
params : ({ pageParam }) => ({
query : {
cursor : pageParam ?? null ,
limit : 20 ,
},
}),
getNextPageParam : ( lastPage ) => lastPage.nextCursor ?? undefined ,
initialPageParam : null ,
});
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery (todosInfiniteQuery);
if (isLoading) return < div >Loading...</ div >;
return (
< div >
{data?.pages. map (( page , i ) => (
< div key = {i}>
{page.todos. map (( todo ) => (
< div key = {todo.id}>{todo.title}</ div >
))}
</ div >
))}
< button
onClick = {() => fetchNextPage ()}
disabled = { ! hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? " Loading more... "
: hasNextPage
? " Load More "
: " No more data " }
</ button >
</ div >
);
}
API Reference
createRQ(client)
Creates a React Query adapter factory.
const rq = createRQ (apiClient);
Parameters:
client - A Contract Kit API client created with createClient()
Returns: A React Query adapter function
rq(contract)
Creates a contract helper with query/mutation options methods.
const helper = rq (getTodo);
Parameters:
contract - A contract created with Contract Kit
Returns: A helper object with methods:
helper.queryOptions(options)
Generate query options for TanStack Query primitives.
const queryOpts = helper. queryOptions ({
path? : { ... }, // Path parameters
query? : { ... }, // Query parameters
key? : readonly unknown[], // Custom query key
// ...any TanStack Query options
});
Returns: Query options object compatible with useQuery, prefetchQuery, fetchQuery, etc.
helper.mutationOptions(options)
Generate mutation options for useMutation.
const mutationOpts = helper. mutationOptions ({
onSuccess? : ( data , variables , context ) => void ,
onError? : ( error , variables , context ) => void ,
onMutate? : ( variables ) => Promise < any > ,
// ...any TanStack Query mutation options
});
Returns: Mutation options object compatible with useMutation
helper.infiniteQueryOptions(options)
Generate infinite query options for pagination.
const infiniteOpts = helper. infiniteQueryOptions ({
params : ( ctx : { pageParam }) => ({
path? : { ... },
query? : { ... },
}),
initialPageParam : any,
getNextPageParam : ( lastPage ) => any,
getPreviousPageParam? : ( firstPage ) => any,
// ...any TanStack Query infinite query options
});
Returns: Infinite query options object compatible with useInfiniteQuery
helper.key(params?)
Generate a stable query key for cache operations.
helper. key (); // ["contractName"]
helper. key ({ path : { id : " 1 " } }); // ["contractName", { path: { id: "1" } }]
Parameters:
params? - Optional parameters object with path, query, etc.
Returns: Query key array
helper.endpoint
Access the underlying endpoint for manual API calls.
const data = await helper.endpoint. call ({ path : { id : " 123 " } });
Error Handling
Errors are typed as ContractError from @contract-kit/client:
import type { ContractError } from " @contract-kit/client " ;
function TodoList () {
const { error } = useQuery ( rq (listTodos). queryOptions ());
if (error) {
return (
< div className = " error " >
< p >Status: {error.status}</ p >
< p >Message: {error.message}</ p >
</ div >
);
}
// ... rest of component
}
Type Inference
All options are fully typed based on your contracts:
// Response types are inferred
const { data } = useQuery (
rq (getTodo). queryOptions ({ path : { id : " 123 " } })
);
// data is typed as: { id: string; title: string; completed: boolean }
// Mutation input is typed
const mutation = useMutation ( rq (createTodo). mutationOptions ());
mutation. mutate ({ body : { title : " New Todo " } });
// body is typed based on the contract's body schema
Best Practices
Use queryOptions for all data fetching
The options-first API works with all TanStack Query primitives and provides
better flexibility than hook wrappers.
Configure global query defaults
Set sensible defaults in your QueryClient for staleTime, retry, and other options
to avoid repeating them in every query.
Invalidate queries after mutations
Use queryClient.invalidateQueries() to keep your UI in sync after data changes.
Use the key() helper to generate the correct query key.
Prefetch data on user interaction
Improve perceived performance by prefetching data when users hover over links
or navigate between pages.
Use optimistic updates for instant feedback
Implement optimistic updates for mutations that should feel instant to users,
like toggling checkboxes or liking posts.
Handle loading and error states
Always provide appropriate UI feedback for loading and error states to
improve user experience.