Features
Forms
Type-safe forms with react-hook-form, Zod, and the Controller pattern.
TheShipStack uses react-hook-form + Zod for all forms.
Pattern
Every form follows the same structure: define a Zod schema, derive the TypeScript type from it, pass the schema to zodResolver, and use Controller for each field.
'use client'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
})
type FormValues = z.infer<typeof schema>
export function ExampleForm() {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
})
function onSubmit(data: FormValues) {
// data is fully typed
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Controller
control={control}
name="name"
render={({ field }) => (
<div>
<Input {...field} placeholder="Name" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
)}
/>
<Controller
control={control}
name="email"
render={({ field }) => (
<div>
<Input {...field} placeholder="Email" type="email" />
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
</div>
)}
/>
<Button type="submit">Submit</Button>
</form>
)
}Conventions
- Always use
Controller— neverregisterdirectly with shadcn/ui inputs - Define the Zod schema in the same file as the form (or a co-located
schema.tsfor complex forms) - Use
z.infer<typeof schema>to derive the TypeScript type — never define it separately - Show field errors inline below the input, not in a toast