React

Contract Kit provides optional integrations for React Query and React Hook Form. Both are fully typed from your contracts.

bun add @contract-kit/react-query @tanstack/react-query
bun add @contract-kit/react-hook-form react-hook-form @hookform/resolvers

Or install both at once with the umbrella package:

bun add @contract-kit/react

React Query

@contract-kit/react-query gives you typed query and mutation options derived from your contracts.

Setup

import { createClient } from "contract-kit";
import { createRQ } from "@contract-kit/react-query";

const client = createClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || "",
});

export const rq = createRQ(client);

Queries

Use rq(contract).queryOptions() with useQuery.

import { useQuery } from "@tanstack/react-query";
import { rq } from "@/app/lib/rq";
import { getTodo } from "@/app/contracts/todo";

function TodoDetail({ id }: { id: string }) {
  const { data, isLoading, error } = useQuery(
    rq(getTodo).queryOptions({ path: { id } })
  );

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

  return <p>{data.title}</p>; // fully typed
}

Mutations

Use rq(contract).mutationOptions() with useMutation.

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { rq } from "@/app/lib/rq";
import { createTodo, listTodos } from "@/app/contracts/todo";

function CreateTodoButton() {
  const queryClient = useQueryClient();

  const mutation = useMutation(
    rq(createTodo).mutationOptions({
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: rq(listTodos).key() });
      },
    })
  );

  return (
    <button onClick={() => mutation.mutate({ body: { title: "New todo" } })}>
      Add Todo
    </button>
  );
}

Query keys

rq(contract).key() generates stable, contract-aware query keys for cache operations.

// Invalidate all getTodo queries
queryClient.invalidateQueries({ queryKey: rq(getTodo).key() });

// Invalidate a specific getTodo query
queryClient.invalidateQueries({ queryKey: rq(getTodo).key({ path: { id: "123" } }) });

Infinite queries

For paginated data, use infiniteQueryOptions.

import { useInfiniteQuery } from "@tanstack/react-query";

const { data, fetchNextPage } = useInfiniteQuery(
  rq(listTodos).infiniteQueryOptions({
    params: ({ pageParam = 0 }) => ({
      query: { offset: pageParam, limit: 10 },
    }),
    getNextPageParam: (lastPage) =>
      lastPage.todos.length < 10 ? undefined : lastPage.total,
  })
);

Prefetching

Prefetch data on the server or on user interaction.

// Server-side prefetching
const queryClient = new QueryClient();
await queryClient.prefetchQuery(
  rq(getTodo).queryOptions({ path: { id: "123" } })
);

// Prefetch on hover
<button onMouseEnter={() =>
  queryClient.prefetchQuery(
    rq(getTodo).queryOptions({ path: { id: "123" } })
  )
}>
  View Todo
</button>

React Hook Form

@contract-kit/react-hook-form gives you form validation derived from your contract's body schema.

Setup

import { rhf } from "@contract-kit/react-hook-form";
import { createTodo } from "@/app/contracts/todo";

const createTodoForm = rhf(createTodo);

Basic form

function CreateTodoForm() {
  const form = createTodoForm.useForm({
    defaultValues: { title: "", completed: false },
  });

  const onSubmit = form.handleSubmit(async (data) => {
    await createTodoEndpoint.call({ body: data });
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("title")} /> {/* type-safe field names */}

      {form.formState.errors.title && (
        <span>{form.formState.errors.title.message}</span>
      )}

      <button type="submit" disabled={form.formState.isSubmitting}>
        Create
      </button>
    </form>
  );
}

Validation runs automatically using your contract's body schema. Field names, types, and error messages are all inferred.

With React Query

Combine form validation with mutations for a complete flow.

function CreateTodoForm() {
  const form = createTodoForm.useForm({
    defaultValues: { title: "", completed: false },
  });

  const mutation = useMutation(
    rq(createTodo).mutationOptions({
      onSuccess: () => form.reset(),
      onError: (error) => {
        form.setError("root", { message: error.message });
      },
    })
  );

  const onSubmit = form.handleSubmit((data) => {
    mutation.mutate({ body: data });
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("title")} />
      {form.formState.errors.title && (
        <span>{form.formState.errors.title.message}</span>
      )}
      {form.formState.errors.root && (
        <span>{form.formState.errors.root.message}</span>
      )}
      <button type="submit" disabled={mutation.isPending}>
        Create
      </button>
    </form>
  );
}

Form options

You can also get raw form options if you want to call useForm yourself.

import { useForm } from "react-hook-form";

const form = useForm(createTodoForm.formOptions({
  defaultValues: { title: "" },
  mode: "onBlur",
}));

Disabling automatic validation

If you want to handle validation manually, set resolverEnabled to false.

const form = createTodoForm.useForm({
  resolverEnabled: false,
});