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.
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) })
);
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),
});
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;