Form Schema and Validation Strategy
This project implements a robust form validation strategy by combining Zod for schema definition, React Hook Form for state management, and a set of custom UI components built on Radix UI. This approach ensures that validation logic is centralized, type-safe, and consistent across the entire frontend application.
Schema-Driven Validation with Zod
The core of the validation strategy is the use of Zod schemas to define the shape and constraints of form data. Instead of defining validation logic within the UI components or manually checking values, each form component defines a formSchema.
For example, in frontend/src/components/Items/AddItem.tsx, the schema defines both the structure and the validation rules:
const formSchema = z.object({
title: z.string().min(1, { message: "Title is required" }),
description: z.string().optional(),
})
This schema serves as the single source of truth. By using z.infer, the project automatically generates a TypeScript type, FormData, which ensures that the rest of the component remains type-safe without manual interface maintenance:
type FormData = z.infer<typeof formSchema>
Type Safety and API Alignment
A critical design decision in this project is ensuring that frontend form schemas align with the backend API requirements. In routes like frontend/src/routes/login.tsx, the project uses the satisfies operator to validate the Zod schema against types generated from the OpenAPI specification:
import type { Body_login_login_access_token as AccessToken } from "@/client"
const formSchema = z.object({
username: z.email(),
password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
}) satisfies z.ZodType<AccessToken>
This pattern prevents runtime errors by catching mismatches between the form's expected data structure and the API's required request body at compile time.
UI Component Abstraction
To maintain a consistent user experience and reduce boilerplate, the project utilizes a set of wrapper components in frontend/src/components/ui/form.tsx. These components encapsulate the logic for accessibility (ARIA attributes) and error display.
The hierarchy typically follows this pattern:
Form: A wrapper aroundFormProviderfrom React Hook Form.FormField: A controlled component that connects a specific field to the form state.FormItem: A container for layout and context.FormControl: A wrapper that injects accessibility attributes likearia-invalidandaria-describedbyinto the input.FormMessage: Automatically displays validation errors associated with the field.
In frontend/src/components/Items/AddItem.tsx, this looks like:
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
Validation Lifecycle and Complex Logic
The project configures React Hook Form with mode: "onBlur" (seen in AddItem.tsx and AddUser.tsx). This design choice ensures that validation errors are not immediately intrusive while the user is typing, but are instead triggered when the user moves to the next field, providing a smoother user experience.
For complex validation logic that involves multiple fields, such as password confirmation, the project uses Zod's .refine() method. This is demonstrated in frontend/src/components/Admin/AddUser.tsx:
const formSchema = z
.object({
email: z.email({ message: "Invalid email address" }),
password: z.string().min(8, { message: "Password must be at least 8 characters" }),
confirm_password: z.string().min(1, { message: "Please confirm your password" }),
// ... other fields
})
.refine((data) => data.password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})
Integration with TanStack Query
Once the form data is validated by React Hook Form, it is passed to a TanStack Query mutation for API submission. Because FormData is inferred from the schema, it can be passed directly to the mutation function, which expects the generated API client types.
In AddItem.tsx, the onSubmit handler triggers the mutation:
const onSubmit = (data: FormData) => {
mutation.mutate(data)
}
// ... inside the component
<form onSubmit={form.handleSubmit(onSubmit)}>
The mutation then uses the generated ItemsService to perform the network request, completing the type-safe flow from user input to API call.
Tradeoffs and Constraints
- Local vs. Global Schemas: Schemas and
FormDatatypes are defined locally within components (e.g.,AddItem.tsxandEditItem.tsxhave separate but similar schemas). While this increases duplication, it allows for granular control over field requirements (e.g., a password might be required for creation but optional for updates). - Boilerplate: The
FormFieldrender prop pattern requires more code than simple input bindings, but it is necessary for the deep integration with Radix UI's accessibility features and automatic error messaging. - Client-Side Only: This strategy focuses on client-side validation. While it mirrors backend constraints, developers must ensure that any changes to the FastAPI models are reflected in the frontend Zod schemas to maintain consistency.