examples/new/.opencode/skills/react-hook-form-zod/SKILL.md
React Hook Form with Zod validation, shadcn/ui Form components, accessibility (autocomplete/name/labels), async submissions, and error handling patterns
npx skillsauth add aexol-studio/axolotl react-hook-form-zodInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Define schema at module level (outside component). Infer type from it — never define form types manually.
import { z } from 'zod';
const formSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
type FormValues = z.infer<typeof formSchema>;
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', password: '' }, // always required
});
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} autoComplete="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Submitting...' : 'Submit'}
</Button>
</form>
</Form>;
{...field} spreads value, onChange, onBlur, name, ref — never set name manually.
autoComplete — "email", "current-password", "new-password", "off"<FormLabel> — renders <label> with correct htmlFor<FormControl> — wires aria-describedby and aria-invalid<FormMessage /> must be inside <FormItem>autoComplete (camelCase) — NOT autocomplete (lowercase). Incorrect casing silently breaks browser autofill.import { toast } from 'sonner';
const onSubmit = async (values: FormValues) => {
try {
await someApiCall(values);
form.reset();
toast.success('Done!');
} catch (error) {
// field-level: form.setError('email', { message: 'Email taken' })
form.setError('root', { message: error instanceof Error ? error.message : 'Something went wrong' });
}
};
// Display root error
{form.formState.errors.root && (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
)}
z.infer<typeof schema> — never manuallydefaultValuesform.reset() after success — not manual state clearingform.setError() for server validation errorsform.formState.isSubmitting for loading statetools
Baseline architecture for Axolotl mobile starter (Expo Router + reusable blocks)
tools
Expo Router conventions for route groups, native headers, and starter navigation
development
i18n baseline and dev-translate setup for Expo mobile starter
development
Starter data layer pattern with React Query + Zeus for Expo app