Where react-hook-form is used
17 files calluseForm. They are the classic forms:
- Auth:
LoginForm,SignupForm,CoachSignupForm,OtpForm,ForgotPasswordForm,ResetPasswordForm,SetInitialPasswordForm. - Account settings:
ChangeNameForm,ChangeEmailForm,ChangePhoneNumberForm,ChangePassword. - Organization:
CreateOrganizationForm,ChangeOrganizationNameForm,InviteMemberForm. - Onboarding and admin:
OnboardingAccountStep,OrganizationForm,NotificationConfigCard.
useState or a reducer with their own validation. Their data is a nested document, not a flat set of fields, and they need features such as drag and drop, autofill across rows and a dirty flag for the exit guard.
The basic pattern
modules/settings/components/ChangeNameForm.tsx is the smallest complete example:
- The schema sits at module scope, next to the component.
useFormgetszodResolver(schema). The values type is inferred from the schema, so do not pass a type argument or write a separate interface.loading={form.formState.isSubmitting}on the submit button.Buttondisables itself while loading.- After a successful save,
form.reset(values)so the form is clean again.
With the shadcn form primitives
For labelled fields with inline messages, useForm, FormField, FormItem, FormLabel, FormControl and FormMessage from @repo/ui/components/form.
FormControl passes the generated id and ARIA attributes to its child through a Radix Slot, so the child must forward props to a real input. Custom inputs such as PhoneField and PasswordInput do.
Zod 4
The repo is on Zod 4 (zod ^4.4.2 in the pnpm catalog) with @hookform/resolvers 5. Use the Zod 4 top level string formats:
Reusable schemas
passwordSchema in packages/utils/lib/password-validation.ts is the only shared schema. Use it for every new password field:
PasswordInput with showPasswordCriteria, which renders its own translated checklist.
Unions keyed on a mode
LoginForm validates four different shapes with one form:
form.clearErrors() and form.setValue("mode", mode). Inside handleSubmit, checking values.mode narrows the type.
Showing errors
Three levels are in use.
For better-auth failures, translate the code:
authClient methods return { data, error } and do not throw. Throwing the error yourself keeps one catch for every branch.
LoginForm only renders the root alert when form.formState.isSubmitted is true, so it does not flash while the user is still typing.
Submitting to a server action
For a domain write, the submit handler calls a server action instead ofauthClient. Wrap the call in toastAction with a translated success and error string, inside try and catch. toastAction rethrows, so an empty catch simply stops the success path. When the action returns an ApiResult, check result.ok and put the explanation in form.setError("root", ...) or an inline message. See Server actions.
Validate on the client for a quick response, and treat the core API as the real validator. Most server actions pass their input straight through. One exception is clients/board/bulk/actions.ts, which parses its input with Zod before fanning a bulk operation out over many trainees.
Editors without react-hook-form
When you build or change one of the large editors, the conventions are:- Keep the document in state and derive
isDirtyby comparing with the last saved snapshot. - Register with
useUnsavedChanges({ isDirty, onSave })so navigation is guarded. See Shared components. - Put validation in a pure function next to the editor and unit test it. Model files such as
templates/builder/program-rows.ts,forms/pdf-sign-editor/pdf-sign-editor-model.tsandcontent/content-utils.tsfollow this. - A save that fails validation must tell the user. The content editor shows a toast and scrolls to the first invalid field instead of ignoring the click.
- Mark required fields with
RequiredMarkfrommodules/shared/components/RequiredMark.tsx.
Specialised inputs
Debounce search inputs.
TraineePicker waits before each server search (SEARCH_DEBOUNCE_MS).