Skip to main content

Overview

@contract-kit/react-hook-form provides seamless integration with React Hook Form, automatically generating form validation from your contract’s body schema. It works with any Standard Schema library including Zod, Valibot, and ArkType.
Form validation is derived directly from your contract’s body schema, ensuring your forms stay in sync with your API contracts.

Installation

npm install @contract-kit/react-hook-form react-hook-form @hookform/resolvers
Requires TypeScript 5.0+ for proper type inference.

Quick Start

Basic Form

Create a form with automatic validation from your contract:
import { rhf } from "@contract-kit/react-hook-form";
import { createTodo } from "@/contracts/todos";

function CreateTodoForm() {
  // Create form helper from contract
  const { useForm } = rhf(createTodo);
  
  // Use the form with automatic validation
  const form = useForm({
    defaultValues: {
      title: "",
      description: "",
    },
  });

  const onSubmit = form.handleSubmit((values) => {
    // values is fully typed and validated
    console.log("Creating todo:", values);
  });

  return (
    <form onSubmit={onSubmit}>
      <div>
        <label htmlFor="title">Title *</label>
        <input
          id="title"
          {...form.register("title")}
          placeholder="What needs to be done?"
        />
        {form.formState.errors.title && (
          <span className="error">
            {form.formState.errors.title.message}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="description">Description</label>
        <textarea
          id="description"
          {...form.register("description")}
          placeholder="Optional details"
        />
        {form.formState.errors.description && (
          <span className="error">
            {form.formState.errors.description.message}
          </span>
        )}
      </div>

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

Usage

With React Query Mutations

Combine with React Query for complete data management:
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { rhf } from "@contract-kit/react-hook-form";
import { rq } from "@/lib/rq";
import { createTodo, listTodos } from "@/contracts/todos";

function CreateTodoForm() {
  const queryClient = useQueryClient();
  
  // Form with validation
  const { useForm } = rhf(createTodo);
  const form = useForm({
    defaultValues: { title: "", description: "" },
  });

  // Mutation to create todo
  const createMutation = useMutation(
    rq(createTodo).mutationOptions({
      onSuccess: () => {
        // Invalidate list after creating
        queryClient.invalidateQueries({
          queryKey: rq(listTodos).key(),
        });
        // Reset form
        form.reset();
      },
    })
  );

  const onSubmit = form.handleSubmit((values) => {
    createMutation.mutate({ body: values });
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("title")} placeholder="Title" />
      {form.formState.errors.title && (
        <p className="error">{form.formState.errors.title.message}</p>
      )}

      <textarea {...form.register("description")} placeholder="Description" />
      {form.formState.errors.description && (
        <p className="error">{form.formState.errors.description.message}</p>
      )}

      <button type="submit" disabled={createMutation.isPending}>
        {createMutation.isPending ? "Creating..." : "Create"}
      </button>

      {createMutation.error && (
        <p className="error">{createMutation.error.message}</p>
      )}
    </form>
  );
}

Update Forms with Default Values

Populate forms with existing data for editing:
import { useQuery, useMutation } from "@tanstack/react-query";
import { rhf } from "@contract-kit/react-hook-form";
import { rq } from "@/lib/rq";
import { getTodo, updateTodo } from "@/contracts/todos";

function EditTodoForm({ id }: { id: string }) {
  // Fetch existing todo
  const { data: todo, isLoading } = useQuery(
    rq(getTodo).queryOptions({ path: { id } })
  );

  // Form with validation
  const { useForm } = rhf(updateTodo);
  const form = useForm({
    defaultValues: {
      title: todo?.title ?? "",
      description: todo?.description ?? "",
      completed: todo?.completed ?? false,
    },
  });

  // Update mutation
  const updateMutation = useMutation(
    rq(updateTodo).mutationOptions()
  );

  const onSubmit = form.handleSubmit((values) => {
    updateMutation.mutate({
      path: { id },
      body: values,
    });
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("title")} />
      {form.formState.errors.title && (
        <span>{form.formState.errors.title.message}</span>
      )}

      <textarea {...form.register("description")} />

      <label>
        <input type="checkbox" {...form.register("completed")} />
        Completed
      </label>

      <button 
        type="submit" 
        disabled={!form.formState.isDirty || updateMutation.isPending}
      >
        {updateMutation.isPending ? "Saving..." : "Save Changes"}
      </button>
    </form>
  );
}

Complex Forms with Nested Fields

Handle complex form structures with nested objects:
import { rhf } from "@contract-kit/react-hook-form";
import { createContractGroup } from "@contract-kit/core";
import { z } from "zod";

// Create contract group
const profile = createContractGroup();

// Define schemas
const profileSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string(),
  preferences: z.object({
    notifications: z.boolean(),
    theme: z.enum(["light", "dark"]),
  }),
});

// Contract with nested schema
const updateProfile = profile
  .put("/api/profile")
  .body(
    z.object({
      name: z.string().min(1),
      email: z.string().email(),
      preferences: z.object({
        notifications: z.boolean(),
        theme: z.enum(["light", "dark"]),
      }),
    })
  )
  .response(200, profileSchema);

function ProfileForm() {
  const { useForm } = rhf(updateProfile);
  const form = useForm({
    defaultValues: {
      name: "",
      email: "",
      preferences: {
        notifications: true,
        theme: "light",
      },
    },
  });

  return (
    <form onSubmit={form.handleSubmit((values) => console.log(values))}>
      <input {...form.register("name")} placeholder="Name" />
      {form.formState.errors.name && (
        <span>{form.formState.errors.name.message}</span>
      )}

      <input {...form.register("email")} type="email" placeholder="Email" />
      {form.formState.errors.email && (
        <span>{form.formState.errors.email.message}</span>
      )}

      <label>
        <input
          type="checkbox"
          {...form.register("preferences.notifications")}
        />
        Enable notifications
      </label>

      <select {...form.register("preferences.theme")}>
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>

      <button type="submit">Save Profile</button>
    </form>
  );
}

Form State Management

Access and utilize form state for better UX:
import { rhf } from "@contract-kit/react-hook-form";
import { createTodo } from "@/contracts/todos";

function CreateTodoForm() {
  const { useForm } = rhf(createTodo);
  const form = useForm({
    defaultValues: { title: "" },
  });

  const {
    formState: {
      errors,
      isSubmitting,
      isDirty,
      isValid,
      dirtyFields,
      touchedFields,
    },
  } = form;

  const onSubmit = (data: { title: string }) => {
    // Handle form submission
    console.log("Form submitted:", data);
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <input {...form.register("title")} />
      
      {/* Show errors */}
      {errors.title && (
        <span className="error">{errors.title.message}</span>
      )}

      {/* Conditional button state */}
      <button 
        type="submit" 
        disabled={!isDirty || !isValid || isSubmitting}
      >
        {isSubmitting ? "Submitting..." : "Submit"}
      </button>

      {/* Show dirty indicator */}
      {isDirty && <span>You have unsaved changes</span>}
    </form>
  );
}

Disabling Validation

Disable schema validation when needed (e.g., for partial form handling):
import { rhf } from "@contract-kit/react-hook-form";
import { createTodo } from "@/contracts/todos";

function PartialTodoForm() {
  const { useForm } = rhf(createTodo);
  const form = useForm({
    resolverEnabled: false, // Disable automatic schema validation
    defaultValues: { title: "" },
  });

  // Add custom validation if needed
  const onSubmit = form.handleSubmit((values) => {
    // Custom validation logic here
    console.log(values);
  });

  return <form onSubmit={onSubmit}>{/* form fields */}</form>;
}

API Reference

rhf(contract)

Creates a React Hook Form adapter for a contract.
const adapter = rhf(createTodo);
Parameters:
  • contract - A contract created with Contract Kit, or its config object
Returns: An adapter object with a useForm method

adapter.useForm(options?)

Returns a React Hook Form useForm result with the contract’s body schema as resolver.
const form = adapter.useForm({
  defaultValues?: { ... },
  resolverEnabled?: boolean, // default: true
  // ...other React Hook Form options
});
Options:
  • defaultValues - Initial form values (typed based on contract body schema)
  • resolverEnabled - Enable/disable automatic schema validation (default: true)
  • All standard React Hook Form options (mode, reValidateMode, etc.)
Returns: Standard React Hook Form result with methods:
  • register(name) - Register form fields
  • handleSubmit(onValid, onInvalid?) - Handle form submission
  • formState - Form state object with errors, validation status, etc.
  • reset(), setValue(), getValues(), and all other React Hook Form methods

Type Inference

Form fields are automatically typed based on your contract’s body schema:
// Contract definition
const createTodo = todos
  .post("/api/todos")
  .body(
    z.object({
      title: z.string().min(1),
      description: z.string().optional(),
      completed: z.boolean().optional(),
    })
  )
  .response(201, todoSchema);

// Form values are inferred
const { useForm } = rhf(createTodo);
const form = useForm();

form.register("title");       // ✓ Valid
form.register("description"); // ✓ Valid
form.register("completed");   // ✓ Valid
form.register("invalid");     // ✗ TypeScript error

Validation Behavior

The schema resolver validates form data at these points:
  1. On blur - When a field loses focus
  2. On change - After the first submission attempt
  3. On submit - Before calling your submit handler
Validation errors are available via form.formState.errors:
{form.formState.errors.title && (
  <span className="error">
    {form.formState.errors.title.message}
  </span>
)}

Standard Schema Support

This package uses the @hookform/resolvers/standard-schema resolver, which works with any Standard Schema compatible library:

Zod

z.object({ ... })

Valibot

v.object({ ... })

ArkType

type({ ... })

Complete Example

Here’s a full example combining all features:
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { rhf } from "@contract-kit/react-hook-form";
import { rq } from "@/lib/rq";
import { createTodo, listTodos } from "@/contracts/todos";

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

  // Form setup
  const { useForm } = rhf(createTodo);
  const form = useForm({
    defaultValues: {
      title: "",
      description: "",
    },
    mode: "onBlur", // Validate on blur
  });

  // Mutation setup
  const createMutation = useMutation(
    rq(createTodo).mutationOptions({
      onSuccess: () => {
        // Invalidate queries and reset form
        queryClient.invalidateQueries({
          queryKey: rq(listTodos).key(),
        });
        form.reset();
      },
      onError: (error) => {
        // Handle server errors
        form.setError("root", {
          type: "server",
          message: error.message,
        });
      },
    })
  );

  // Submit handler
  const onSubmit = form.handleSubmit((values) => {
    createMutation.mutate({ body: values });
  });

  const { errors, isDirty, isValid, isSubmitting } = form.formState;

  return (
    <form onSubmit={onSubmit}>
      <div className="form-group">
        <label htmlFor="title">Title *</label>
        <input
          id="title"
          {...form.register("title")}
          placeholder="What needs to be done?"
          aria-invalid={!!errors.title}
        />
        {errors.title && (
          <span className="error" role="alert">
            {errors.title.message}
          </span>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="description">Description</label>
        <textarea
          id="description"
          {...form.register("description")}
          placeholder="Optional details"
          rows={3}
        />
        {errors.description && (
          <span className="error" role="alert">
            {errors.description.message}
          </span>
        )}
      </div>

      <button
        type="submit"
        disabled={!isDirty || !isValid || createMutation.isPending}
      >
        {createMutation.isPending ? "Creating..." : "Create Todo"}
      </button>

      {/* Server error display */}
      {errors.root && (
        <div className="error" role="alert">
          {errors.root.message}
        </div>
      )}

      {/* Unsaved changes warning */}
      {isDirty && !isSubmitting && (
        <p className="warning">You have unsaved changes</p>
      )}
    </form>
  );
}

Best Practices

Display error messages next to form fields so users know what to fix. Use form.formState.errors to access validation errors.
Use isDirty, isSubmitting, isValid to disable buttons and show loading states for better UX.
Call form.reset() after successful mutations to clear the form and prepare for new input.
Use React Query mutations with React Hook Form for complete control over form submission, error handling, and cache invalidation.
Configure validation mode to balance between user experience and performance. onBlur mode provides immediate feedback without excessive re-renders.
Use form.setError() to display server-side validation errors alongside client-side validation errors.