Forms
-
Use React Hook Form with Zod for form validation, adding
react-hook-form,@hookform/resolvers, andzodto the package’s dependencies first if the module does not already declare them. This combination provides type-safe validation and excellent performance:import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; // Define validation schema const formSchema = z.object({ name: z.string({ required_error: t('nameRequired', 'Name is required'), }).min(1, t('nameRequired', 'Name is required')), email: z.string().email(t('invalidEmail', 'Invalid email address')), age: z.number().min(18, t('ageMustBe18', 'Age must be at least 18')), password: z.string().min(8, t('passwordTooShort', 'Password must be at least 8 characters')), confirmPassword: z.string(), }).refine((data) => data.password === data.confirmPassword, { message: t('passwordsDoNotMatch', 'Passwords do not match'), path: ['confirmPassword'], }); // Use in component const { handleSubmit, control, formState: { errors }, } = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: '', email: '', age: 18, password: '', confirmPassword: '', }, }); const onSubmit = async (data: z.infer<typeof formSchema>) => { try { await saveData(data); showSnackbar({ title: t('saved', 'Saved successfully') }); } catch (error) { showSnackbar({ kind: 'error', title: t('errorSaving', 'Error saving'), subtitle: error instanceof Error ? error.message : t('unknownError', 'Unknown error'), }); } }; -
Use
Controllerfrom React Hook Form when integrating with Carbon components that don’t follow standard HTML input patterns:<Controller name="status" control={control} render={({ field: { onBlur, onChange, value } }) => ( <ComboBox id="status" items={statusOptions} itemToString={(item) => item?.label ?? ''} onBlur={onBlur} onChange={({ selectedItem }) => onChange(selectedItem)} selectedItem={value} titleText={t('status', 'Status')} /> )} /> -
Handle form submission errors appropriately. Display validation errors inline and show snackbars for server errors:
const onSubmit = async (data: FormData) => { try { setIsSubmitting(true); await saveFormData(data); await closeWorkspace({ discardUnsavedChanges: true }); showSnackbar({ title: t('formSaved', 'Form saved successfully') }); } catch (error) { // Server errors shown via snackbar showSnackbar({ kind: 'error', title: t('errorSavingForm', 'Error saving form'), subtitle: error instanceof Error ? error.message : t('unknownError', 'Unknown error'), }); } finally { setIsSubmitting(false); } }; -
Use loading states during form submission to prevent duplicate submissions:
const [isSubmitting, setIsSubmitting] = useState(false); <Button type="submit" disabled={isSubmitting} > {isSubmitting ? <InlineLoading description={t('saving', 'Saving...')} /> : t('save', 'Save')} </Button> -
When creating Zod schemas that include translated error messages, create the schema using a function that accepts the translation function as a parameter. This ensures schemas are properly localized and can be memoized:
import type { TFunction } from 'i18next'; // Good - schema factory function const createFormSchema = (t: TFunction) => z.object({ name: z.string().min(1, t('nameRequired', 'Name is required')), email: z.string().email(t('invalidEmail', 'Invalid email address')), }); // Use in component with useMemo const { t } = useTranslation(); const formSchema = useMemo(() => createFormSchema(t), [t]); const { control, handleSubmit } = useForm({ resolver: zodResolver(formSchema), }); -
For Workspace v2 forms in the patient chart, use
PatientWorkspace2DefinitionPropsfrom@openmrs/esm-patient-common-liband wrap the form content inWorkspace2:import { Workspace2 } from '@openmrs/esm-framework'; import { type PatientWorkspace2DefinitionProps } from '@openmrs/esm-patient-common-lib'; interface MyFormProps { encounterUuid?: string; } export default function MyForm({ closeWorkspace, workspaceProps, groupProps, }: PatientWorkspace2DefinitionProps<MyFormProps, object>) { const patientUuid = groupProps?.patientUuid; const encounterUuid = workspaceProps?.encounterUuid; const { formState: { isDirty }, } = useForm(); return ( <Workspace2 title={t('myForm', 'My form')} hasUnsavedChanges={isDirty}> {/* Use patientUuid and encounterUuid to render the form */} </Workspace2> ); } -
Use the
hasUnsavedChangesprop onWorkspace2to warn users when they try to close a workspace form with unsaved changes:const { formState: { isDirty }, } = useForm(...); return ( <Workspace2 title={t('editItem', 'Edit item')} hasUnsavedChanges={isDirty}> {/* Form content */} </Workspace2> ); -
Use
closeWorkspace({ discardUnsavedChanges: true })after a successful submit, andcloseWorkspace()when the user cancels:const onSubmit = async (data: FormData) => { try { await saveData(data); await mutate(); // Update SWR cache await closeWorkspace({ discardUnsavedChanges: true }); showSnackbar({ title: t('saved', 'Saved successfully') }); } catch (error) { showSnackbar({ kind: 'error', title: t('errorSaving', 'Error saving'), subtitle: error instanceof Error ? error.message : t('unknownError', 'Unknown error'), }); } }; // Cancel button <Button kind="secondary" onClick={() => closeWorkspace()}> {t('cancel', 'Cancel')} </Button> -
Display form-level errors using Carbon’s
InlineNotificationcomponent. Consider separate error states for create vs update operations:const [errorCreating, setErrorCreating] = useState<Error | null>(null); const [errorUpdating, setErrorUpdating] = useState<Error | null>(null); {errorCreating && ( <InlineNotification role="alert" kind="error" lowContrast title={t('errorCreating', 'Error creating')} subtitle={errorCreating.message} /> )} {errorUpdating && ( <InlineNotification role="alert" kind="error" lowContrast title={t('errorUpdating', 'Error updating')} subtitle={errorUpdating.message} /> )}