Building React (general) with open-source tools
This guide details the implementation of a type-safe form architecture in React 19 using React Hook Form and Zod, specifically optimized for integration with Server Actions and shadcn/ui components. This approach reduces boilerplate while ensuring robust client and server-side validation.
Define the Validation Schema and Types
Create a centralized schema using Zod. This schema serves as the single source of truth for both client-side validation logic and TypeScript type definitions, preventing drift between the UI and the API.
import { z } from 'zod';
export const profileSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Invalid email address'),
role: z.enum(['admin', 'user', 'guest'])
});
export type ProfileFormValues = z.infer<typeof profileSchema>;⚠ Common Pitfalls
- •Defining the schema inside the component, which causes unnecessary re-creations on every render.
- •Forgetting to export the inferred type, leading to manual type definitions that can fall out of sync.
Initialize React Hook Form with Zod Resolver
Configure the useForm hook using the zodResolver. This connects Zod's validation engine to the form's internal state management.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { profileSchema, type ProfileFormValues } from './schema';
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: '',
email: '',
role: 'user'
}
});⚠ Common Pitfalls
- •Omitting defaultValues, which can lead to 'uncontrolled to controlled' input warnings in React.
- •Not passing the generic type to useForm, resulting in 'any' types for form values.
Implement Server Action with State Handling
In React 19, use the useActionState hook to manage the lifecycle of a form submission, including pending states and server-side error responses.
import { useActionState } from 'react';
import { updateProfileAction } from './actions';
const [state, formAction, isPending] = useActionState(updateProfileAction, {
success: false,
errors: {}
});⚠ Common Pitfalls
- •Trying to use useActionState in a Server Component; it must be used in a Client Component ('use client').
- •Not providing an initial state object, which can cause runtime errors when accessing state.errors.
Connect React Hook Form to the Server Action
Bridge the gap between the client-side form state and the Server Action. Use the form.handleSubmit method to perform final client validation before triggering the action via the form's action prop or a manual call.
<form action={formAction} onSubmit={form.handleSubmit(() => {
const formData = new FormData();
Object.entries(form.getValues()).forEach(([key, value]) => {
formData.append(key, value);
});
formAction(formData);
})}>
{/* Form Fields */}
</form>⚠ Common Pitfalls
- •Directly passing form.handleSubmit to the action prop without handling the FormData conversion.
- •Failing to prevent default behavior if manual action triggering is required.
Build Accessible Form Fields with shadcn/ui
Utilize the FormField pattern to ensure accessible labels, descriptions, and error messages are linked via ARIA attributes automatically.
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} disabled={isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>⚠ Common Pitfalls
- •Hardcoding IDs for labels and inputs instead of relying on the context-provided IDs from the FormField component.
- •Forgetting to disable inputs during the isPending state, allowing double submissions.
Synchronize Server Errors Back to the Form
After the Server Action completes, if there are server-side validation errors (e.g., 'username taken'), use the setError method to display them on the relevant fields.
useEffect(() => {
if (state.errors) {
Object.entries(state.errors).forEach(([key, message]) => {
form.setError(key as keyof ProfileFormValues, {
type: 'server',
message: message as string
});
});
}
}, [state.errors, form]);⚠ Common Pitfalls
- •Infinite loops in useEffect if the state object reference is not stable.
- •Overwriting client-side validation errors with stale server errors if the user starts typing again.
What you built
By combining React Hook Form's client-side efficiency with React 19's useActionState and Zod's schema safety, you create a form architecture that is resilient, accessible, and type-safe from the UI layer down to the server execution logic.