Skip to main content

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:
lib/api-client.ts
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:
lib/rq.ts
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:
app/providers.tsx
"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:
app/todos/[id]/page.tsx
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

The options-first API works with all TanStack Query primitives and provides better flexibility than hook wrappers.
Set sensible defaults in your QueryClient for staleTime, retry, and other options to avoid repeating them in every query.
Use queryClient.invalidateQueries() to keep your UI in sync after data changes. Use the key() helper to generate the correct query key.
Improve perceived performance by prefetching data when users hover over links or navigate between pages.
Implement optimistic updates for mutations that should feel instant to users, like toggling checkboxes or liking posts.
Always provide appropriate UI feedback for loading and error states to improve user experience.