This document outlines the standardized form validation patterns and guidelines established in PR #39590 to ensure consistent user experience across Rocket.Chat forms.
The form validation standardization aims to:
- Improve accessibility by keeping submit buttons enabled and letting validation run on submit
- Provide consistent UX with validation triggered on form submission and re-validation on field changes
- Prevent unnecessary API calls by using dirty-checks and appropriate revalidation modes
- Enhance user feedback with clear error messages and proper ARIA attributes
Forms should use mode: 'onSubmit' in react-hook-form to trigger initial validation only when the user attempts to submit the form.
Why: This approach improves accessibility by:
- Keeping submit buttons enabled (allowing screen readers and keyboard users to discover validation requirements)
- Avoiding premature error messages that can confuse users
- Letting users complete the form at their own pace before seeing validation feedback
Example:
const {
control,
formState: { errors, isDirty, isSubmitting },
handleSubmit,
} = useForm<FormData>({
mode: 'onSubmit', // This can be omitted, `onSubmit` it's the default mode value
defaultValues: initialData,
});After the first submit attempt, forms should revalidate fields intelligently:
For most forms, use the default onChange revalidation to provide immediate feedback as users correct errors.
For forms with async validation (e.g., username availability, email uniqueness checks), explicitly set reValidateMode: 'onBlur' to avoid excessive API calls.
Example with async validation:
const {
control,
formState: { errors, isDirty },
handleSubmit,
} = useForm<FormData>({
reValidateMode: 'onBlur', // Avoid API calls on every keystroke
defaultValues: initialData,
});Use the useFormSubmitWithDirtyCheck hook to provide user-friendly feedback when attempting to save unchanged forms.
Usually applicable on edit forms, where fields are already populated.
Purpose:
- Prevents unnecessary save operations on unchanged data
- Shows informative toast message: "No changes to save"
- Maintains accessibility by keeping buttons enabled
Signature:
Receives a callback as the first parameter (your submit handler), and an object as the second parameter containing isDirty and an optional noChangesMessage translation key, to be dispatched in the info toast.
Usage:
import { useFormSubmitWithDirtyCheck } from '/hooks/useFormSubmitWithDirtyCheck';
const handleSave = useFormSubmitWithDirtyCheck(
async (data: FormData) => {
try {
await saveData(data);
dispatchToastMessage({ type: 'success', message: t('Saved') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
{ isDirty }
);
// In JSX:
<form onSubmit={handleSubmit(handleSave)}>When to use dirty-check:
This hook is recommended when the same form component is used for both creation (new) and editing existing data. The hook intelligently handles both scenarios:
- ✅ Create mode (no existing data): Allows submission without dirty check
- ✅ Edit mode (existing data): Shows "No changes to save" toast when form is unchanged
- ✅ Unified component: Simplifies logic by handling both create and edit in one place
import { useForm, Controller } from 'react-hook-form';
import { useFormSubmitWithDirtyCheck } from '../../../hooks/useFormSubmitWithDirtyCheck';
type FormData = {
name: string;
email: string;
};
const MyForm = ({ data, onSave }: FormProps) => {
const { t } = useTranslation();
const {
control,
formState: { errors, isDirty, isSubmitting },
handleSubmit,
} = useForm<FormData>({
defaultValues: data || {},
});
const handleFormSubmit = useFormSubmitWithDirtyCheck(
async (formData: FormData) => {
await onSave(formData);
},
{ isDirty }
);
return (
<form onSubmit={handleSubmit(handleFormSubmit)} id={formId}>
{/* Form fields */}
</form>
);
};<Button
primary
type='submit'
form={formId}
loading={isSubmitting}
>
{t('Save')}
</Button>Key Points:
- Use
loading={isSubmitting}to show loading state during submission - Never disable the save button (keep enabled for a11y)
- Always connect button to form via
form={formId}attribute
When updating an existing form to follow these guidelines:
- Use
modeto'onSubmit'inuseForm - Add
reValidateMode: 'onBlur'if form has async validation - Wrap submit handler with
useFormSubmitWithDirtyCheck(for create and edit forms) - Add ARIA attributes:
aria-describedby,aria-invalid,role='alert'when applicable - Button states:
loading={isSubmitting}, but neverdisabled - Verify accessibility with screen reader testing
// Bad - prevents discovery of validation requirements
<Button disabled={!isValid || !isDirty}>Save</Button>// Good - accessible and provides feedback
<Button
type='submit'
disabled={existingId ? !isDirty : false}
loading={isSubmitting}
>
Save
</Button>// Bad - shows errors immediately, poor UX
useForm({ mode: 'onChange' })// Good - validates on submit, revalidates on change
useForm({ mode: 'onSubmit' })// Bad - causes API call on every keystroke
useForm({
mode: 'onSubmit',
// Uses default 'onChange' revalidation - too many API calls!
})// Good - reduces API calls while maintaining feedback
useForm({
mode: 'onSubmit',
reValidateMode: 'onBlur',
})