Skip to main content

Forms pattern

Forms use React Hook Form with custom controlled components as described in the shadcn React Hook Form guide: full control over markup and styling via <Controller> and shadcn <Field /> primitives.

Conventions

  • Schema: Define form shape with a Zod schema; use z.infer<typeof schema> for useForm<T>.
  • Setup: useForm with resolver: zodResolver(schema) and defaultValues.
  • Fields: Use React Hook Form's <Controller> with control and a render prop that receives { field, fieldState }. Wrap each control in shadcn <Field data-invalid={fieldState.invalid}>, use <FieldLabel>, put the input/select/textarea inside and spread field (or use field.value / field.onChange for non-inputs). Set aria-invalid={fieldState.invalid} on the control; show errors with <FieldError errors={[fieldState.error]} /> when fieldState.invalid.
  • Form context: Wrap the form content in <FormProvider {...form}> so field components can obtain control via useFormContext() and do not need to receive control as a prop.
  • ControlledField: A shared primitive under src/components/forms/fields that wraps Controller with the Field + FieldError pattern. It accepts name, optional orientation and className, and a children render prop (field, fieldState) => ReactNode. Control is always taken from useFormContext(); use only inside <FormProvider>. Use it when building reusable field components so they don't duplicate Controller + Field + FieldError.
  • Reusable field components: Build on ControlledField (or Controller + Field directly) under src/components/forms/fields. InputField (optional label, labelAddon), NumberField (optional label, labelAddon; type="number" with valueAsNumber so form state is numeric; empty input → undefined), CheckboxField (label, optional description), Button (generic: type?: 'submit' | 'button' | 'reset', default 'submit'; wraps Field + shadcn Button), and SubmitButton (abstraction over Button with type="submit"). Field components do not take control; they must be used inside FormProvider.
  • Submit: Use a TanStack Query mutation whose mutationFn is a frontend service function (e.g. authService.login). In the submit handler (form.handleSubmit(onSubmit)): call mutation.mutateAsync(data); on returned result, if statusCode is 2xx handle success (redirect, invalidate); if 4xx, set the response message as a root error (form.setError('root', { type: 'server', message })) so it can be shown as a form-level alert, and if fieldErrors are present apply them with form.setError(field, { type: 'server', message }). Server errors (5xx) throw from the service layer; handle them in the mutation’s onError (e.g. show a toaster). Disable the submit button when mutation.isPending. See responses.
  • Generic Form component: A Form component in src/components/forms abstracts useForm, useMutation, and submit/error handling. It accepts type parameters TInput, TData (form values and mutationFn input), and TOutput (success response data). Props: schema, mutationFn(data: TData) (returns ClientApiResponse<TOutput>; throws on 5xx), defaultValues, optional onSuccess(result: SuccessResponse&lt;TOutput&gt;) (called only when result.type === 'success'; result.data is typed), optional onError(response: ServerErrorResponse) (called when the mutation throws; the Form maps ServerError to ServerErrorResponse inside useMutation so both the default handler and custom onError receive ServerErrorResponse), and a children render prop that receives { form, mutation, onSubmit }. On client-error (4xx) the Form sets the response message as a root error and renders a form-level alert; it also applies fieldErrors. Use it to avoid repeating the same wiring in every form.
  • Validation modes: mode: 'onChange' | 'onBlur' | 'onSubmit' | 'onTouched' | 'all' (default onSubmit).

Implementation

The boilerplate realizes this pattern by wiring shadcn and react-hook-form in the desktop app (see stack form-handling and the boilerplate-regenerate skill): dependencies installed, shadcn Field components available, a generic Form component in src/components/forms (abstracts useForm, useMutation, onSubmit, and error handling; renders a form-level alert for 4xx root errors and applies fieldErrors; children receive form, mutation, onSubmit), ControlledField and reusable field components in src/components/forms/fields (InputField, NumberField, CheckboxField, Button, SubmitButton) when used, and at least one form (e.g. login) built with Form or FormProvider and zodResolver.

References

  • Stack: form-handling, validation, responses, requests, ui-components.
  • Canonical reference: shadcn React Hook Form — the doc remains the canonical reference for markup and examples.