This page is about HTML forms in the dashboard. The trainee facing form builder is a product feature and is described in Feature areas.

Where react-hook-form is used

17 files call useForm. 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.
The large editors (program builder, nutrition builder, form builder, content editor, trainee dialogs) hold their state in 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:
Points to copy:
  • The schema sits at module scope, next to the component.
  • useForm gets zodResolver(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. Button disables 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, use Form, 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:
Its messages are English strings. Forms that show them use PasswordInput with showPasswordCriteria, which renders its own translated checklist.

Unions keyed on a mode

LoginForm validates four different shapes with one form:
Switching tabs calls 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 of authClient. 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 isDirty by 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.ts and content/content-utils.ts follow 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 RequiredMark from modules/shared/components/RequiredMark.tsx.

Specialised inputs

Debounce search inputs. TraineePicker waits before each server search (SEARCH_DEBOUNCE_MS).