Guides

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.

45 minutes6 steps
1

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.

schema.ts
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.
2

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.

ProfileForm.tsx
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.
3

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.

ProfileForm.tsx
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.
4

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.

ProfileForm.tsx
<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.
5

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.

ProfileForm.tsx
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.
6

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.

ProfileForm.tsx
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.