Build robust forms with React Hook Form and Zod validation
# Form Handling Patterns for Google Antigravity
Create robust, type-safe forms with excellent UX using React Hook Form and Zod with Google Antigravity IDE.
## Form Setup with React Hook Form
```typescript
// components/forms/user-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Define schema with Zod
const userFormSchema = z.object({
name: z.string()
.min(2, "Name must be at least 2 characters")
.max(100, "Name must be less than 100 characters"),
email: z.string()
.email("Please enter a valid email address"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain an uppercase letter")
.regex(/[a-z]/, "Password must contain a lowercase letter")
.regex(/[0-9]/, "Password must contain a number"),
confirmPassword: z.string(),
role: z.enum(["user", "admin", "moderator"]),
bio: z.string().max(500).optional(),
notifications: z.object({
email: z.boolean(),
push: z.boolean(),
sms: z.boolean()
})
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"]
});
type UserFormData = z.infer<typeof userFormSchema>;
interface UserFormProps {
defaultValues?: Partial<UserFormData>;
onSubmit: (data: UserFormData) => Promise<void>;
}
export function UserForm({ defaultValues, onSubmit }: UserFormProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
watch,
reset,
setError,
clearErrors
} = useForm<UserFormData>({
resolver: zodResolver(userFormSchema),
defaultValues: {
role: "user",
notifications: { email: true, push: false, sms: false },
...defaultValues
},
mode: "onBlur" // Validate on blur for better UX
});
const handleFormSubmit = async (data: UserFormData) => {
try {
await onSubmit(data);
reset();
} catch (error) {
if (error instanceof APIError) {
// Set server-side errors
if (error.field) {
setError(error.field as keyof UserFormData, {
type: "server",
message: error.message
});
} else {
setError("root.serverError", { message: error.message });
}
}
}
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{errors.root?.serverError && (
<div className="alert alert-error">{errors.root.serverError.message}</div>
)}
<div className="form-group">
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
{...register("name")}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
/>
{errors.name && (
<span id="name-error" className="error">{errors.name.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register("email")}
aria-invalid={!!errors.email}
/>
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register("password")}
aria-invalid={!!errors.password}
/>
{errors.password && <span className="error">{errors.password.message}</span>}
<PasswordStrength password={watch("password")} />
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
aria-invalid={!!errors.confirmPassword}
/>
{errors.confirmPassword && (
<span className="error">{errors.confirmPassword.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="role">Role</label>
<select id="role" {...register("role")}>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="moderator">Moderator</option>
</select>
</div>
<fieldset>
<legend>Notification Preferences</legend>
<label>
<input type="checkbox" {...register("notifications.email")} />
Email notifications
</label>
<label>
<input type="checkbox" {...register("notifications.push")} />
Push notifications
</label>
<label>
<input type="checkbox" {...register("notifications.sms")} />
SMS notifications
</label>
</fieldset>
<button type="submit" disabled={isSubmitting || !isDirty}>
{isSubmitting ? "Saving..." : "Save"}
</button>
</form>
);
}
```
## Dynamic Form Fields
```typescript
// components/forms/dynamic-form.tsx
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const teamSchema = z.object({
name: z.string().min(1, "Team name is required"),
members: z.array(z.object({
name: z.string().min(1, "Member name is required"),
email: z.string().email("Valid email required"),
role: z.enum(["lead", "developer", "designer"])
})).min(1, "At least one member is required")
});
type TeamFormData = z.infer<typeof teamSchema>;
export function TeamForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<TeamFormData>({
resolver: zodResolver(teamSchema),
defaultValues: {
name: "",
members: [{ name: "", email: "", role: "developer" }]
}
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "members"
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("name")} placeholder="Team name" />
{errors.name && <span>{errors.name.message}</span>}
<div className="members-list">
{fields.map((field, index) => (
<div key={field.id} className="member-row">
<input
{...register(`members.${index}.name`)}
placeholder="Name"
/>
<input
{...register(`members.${index}.email`)}
placeholder="Email"
/>
<select {...register(`members.${index}.role`)}>
<option value="lead">Lead</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
</select>
<button type="button" onClick={() => move(index, index - 1)} disabled={index === 0}>Up</button>
<button type="button" onClick={() => move(index, index + 1)} disabled={index === fields.length - 1}>Down</button>
<button type="button" onClick={() => remove(index)} disabled={fields.length === 1}>Remove</button>
</div>
))}
</div>
<button type="button" onClick={() => append({ name: "", email: "", role: "developer" })}>
Add Member
</button>
<button type="submit">Create Team</button>
</form>
);
}
```
## File Upload Handling
```typescript
// components/forms/file-upload.tsx
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
const uploadSchema = z.object({
file: z
.custom<FileList>()
.refine((files) => files?.length === 1, "File is required")
.refine((files) => files?.[0]?.size <= MAX_FILE_SIZE, "Max file size is 5MB")
.refine(
(files) => ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type),
"Only .jpg, .png, and .webp formats are supported"
)
});
export function FileUploadForm() {
const { register, handleSubmit, watch, formState: { errors } } = useForm({
resolver: zodResolver(uploadSchema)
});
const selectedFile = watch("file")?.[0];
return (
<form onSubmit={handleSubmit(async (data) => {
const formData = new FormData();
formData.append("file", data.file[0]);
await fetch("/api/upload", { method: "POST", body: formData });
})}>
<input
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(",")}
{...register("file")}
/>
{selectedFile && (
<img src={URL.createObjectURL(selectedFile)} alt="Preview" className="preview" />
)}
{errors.file && <span className="error">{errors.file.message}</span>}
<button type="submit">Upload</button>
</form>
);
}
```
## Best Practices
1. **Define schemas separately** for reuse and testing
2. **Use mode: onBlur** for better UX during typing
3. **Handle server errors** with setError
4. **Show loading states** during submission
5. **Implement proper aria attributes** for accessibility
6. **Use useFieldArray** for dynamic fields
7. **Validate files** before upload
Google Antigravity generates type-safe form code and provides real-time validation suggestions.This forms prompt is ideal for developers working on:
By using this prompt, you can save hours of manual coding and ensure best practices are followed from the start. It's particularly valuable for teams looking to maintain consistency across their forms implementations.
Yes! All prompts on Antigravity AI Directory are free to use for both personal and commercial projects. No attribution required, though it's always appreciated.
This prompt works excellently with Claude, ChatGPT, Cursor, GitHub Copilot, and other modern AI coding assistants. For best results, use models with large context windows.
You can modify the prompt by adding specific requirements, constraints, or preferences. For forms projects, consider mentioning your framework version, coding style, and any specific libraries you're using.