Skip to main content

Overview

We use react-hook-form v7 with Zod 4 for form validation. Ship provides a useApiForm hook that automatically resolves the Zod schema from a typed API endpoint.

useApiForm — Auto Schema Resolution

import { useApiForm, useApiMutation } from 'hooks';
import { apiClient } from 'services/api-client.service';
import { handleApiError } from 'utils';

const form = useApiForm(apiClient.projects.create); // auto-resolves Zod schema
const { mutate } = useApiMutation(apiClient.projects.create);

const onSubmit = form.handleSubmit((data) =>
  mutate(data, { onError: (e) => handleApiError(e, form.setError) })
);

Manual Form Setup

You can also use useForm directly with a Zod schema:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  firstName: z.string().min(1, 'Please enter First name').max(100),
  lastName: z.string().min(1, 'Please enter Last name').max(100),
  email: z.email('Email format is incorrect.'),
});

type FormParams = z.infer<typeof schema>;

const Page = () => {
  const { register, handleSubmit, formState: { errors } } = useForm<FormParams>({
    resolver: zodResolver(schema),
  });
};
This repo uses Zod 4. Use z.email(), z.url(), z.uuid() instead of Zod 3’s z.string().email().

Error Handling

Use handleApiError(e, setError) from utils to map server errors to form fields or show Sonner toasts:
import { handleApiError } from 'utils';

const onSubmit = (data) => mutate(data, {
  onError: (e) => handleApiError(e, form.setError),
});

Complete Form Example

import Head from 'next/head';
import { LayoutType, Page, ScopeType } from 'components';
import { useApiForm, useApiMutation } from 'hooks';
import { apiClient } from 'services/api-client.service';
import { handleApiError } from 'utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

const CreateProjectPage = () => {
  const form = useApiForm(apiClient.projects.create);
  const { mutate, isPending } = useApiMutation(apiClient.projects.create);

  const onSubmit = form.handleSubmit((data) =>
    mutate(data, { onError: (e) => handleApiError(e, form.setError) })
  );

  return (
    <Page scope={ScopeType.PRIVATE} layout={LayoutType.MAIN}>
      <Head>
        <title>Create Project</title>
      </Head>
      <form onSubmit={onSubmit} className="space-y-4 p-4">
        <Input
          {...form.register('name')}
          placeholder="Project name"
        />
        {form.formState.errors.name && (
          <p className="text-sm text-destructive">{form.formState.errors.name.message}</p>
        )}
        <Button type="submit" disabled={isPending}>
          {isPending ? 'Creating...' : 'Create'}
        </Button>
      </form>
    </Page>
  );
};

export default CreateProjectPage;