On this page

Guides

Validation

Validate a form field, and write the message that tells someone how to fix it.

Every Luke UI form field can report a problem. An invalid field takes the danger border colour and renders a message beneath the control.

Validation: Invalid fields
import { Button } from '@luke-ui/react/button';
import { CheckboxField } from '@luke-ui/react/checkbox-field';
import { Cluster } from '@luke-ui/react/cluster';
import { ComboboxField, ComboboxItem } from '@luke-ui/react/combobox-field';
import { Form } from '@luke-ui/react/form';
import { Stack } from '@luke-ui/react/stack';
import { TextInputField } from '@luke-ui/react/text-input-field';
import type { SubmitEvent } from 'react';
import { useState } from 'react';

const countries = [
	{ id: 'australia', label: 'Australia' },
	{ id: 'canada', label: 'Canada' },
	{ id: 'new-zealand', label: 'New Zealand' },
	{ id: 'united-states', label: 'United States' },
];

type Errors = { country?: string; email?: string; terms?: string };

export default () => {
	const [errors, setErrors] = useState<Errors>({});

	function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
		event.preventDefault();
		const data = new FormData(event.currentTarget);
		const emailValue = data.get('emailAddress');
		const email = typeof emailValue === 'string' ? emailValue : '';

		setErrors({
			country: data.get('country') ? undefined : 'Choose your work location.',
			email: email.includes('@') ? undefined : 'Enter a valid email address.',
			terms: data.get('terms') ? undefined : 'Accept the terms before you continue.',
		});
	}

	return (
		<Form onSubmit={handleSubmit} validationBehavior="aria">
			<Stack gap="space.16" style={{ maxInlineSize: '20rem' }}>
				<TextInputField
					errorMessage={errors.email}
					label="Email address"
					name="emailAddress"
					placeholder="[email protected]"
				/>
				<ComboboxField
					defaultItems={countries}
					errorMessage={errors.country}
					label="Work location"
					name="country"
					placeholder="Choose a country"
				>
					{(item) => <ComboboxItem>{item.label}</ComboboxItem>}
				</ComboboxField>
				<CheckboxField
					errorMessage={errors.terms}
					label="I accept the terms of service"
					name="terms"
				/>
				<Cluster>
					<Button type="submit">Create account</Button>
				</Cluster>
			</Stack>
		</Form>
	);
};

Pass errorMessage for a controlled error from a form library, your server, or your own state. The field is invalid as soon as you pass a non-empty message. When the field should generate its own message instead, from a constraint or a validate rule, leave errorMessage out entirely.

Let the browser validate

Set isRequired on a field or radio group to require a value before the form submits. Pass minLength, maxLength, or pattern on TextInputField to state a rule for the text. Pass type="email" or type="url" to check the value's format.

Leave out errorMessage. The field renders the message the constraint produces. Constraint errors normally appear after someone blurs a field or submits the form.

Validation: Constraint validation
import { Button } from '@luke-ui/react/button';
import { Cluster } from '@luke-ui/react/cluster';
import { Form } from '@luke-ui/react/form';
import { Stack } from '@luke-ui/react/stack';
import { TextInputField } from '@luke-ui/react/text-input-field';

export default () => {
	return (
		<Form necessityIndicator="required-label">
			<Stack gap="space.16" style={{ maxInlineSize: '20rem' }}>
				<TextInputField isRequired label="Email address" name="emailAddress" type="email" />
				<Cluster>
					<Button type="submit">Create account</Button>
				</Cluster>
			</Stack>
		</Form>
	);
};

Native validation is the default. It prevents form submission when a field fails validation, so a form-library submit handler never runs.

Set validationBehavior="aria" on Form when a form library owns validation. This lets the library receive the submit event and decide whether the form is valid. An individual field can override the Form setting.

Native validation messages

Constraint messages come from the browser. They follow the browser or operating system language setting.

Keep the browser message when it matches the product language and names the fix clearly enough. Pass your own copy when the message must use the field's wording, match a style guide, or name a product rule the browser cannot describe.

On composed fields such as TextInputField, errorMessage is React content for a controlled error. To customise a constraint message from the field's validity state, compose the field primitives and pass a render function to FieldError. For a rule the browser cannot check, prefer validate on the field instead.

Custom validation

Pass a validate function for a rule the browser cannot check, such as a reserved username. The function takes the value and returns the validation message, or null when the value is valid. Leave out errorMessage. The field renders the message validate returns. Those messages normally appear after blur or submit, not between keystrokes.

Validation: Custom rule
import { Button } from '@luke-ui/react/button';
import { Cluster } from '@luke-ui/react/cluster';
import { Form } from '@luke-ui/react/form';
import { Stack } from '@luke-ui/react/stack';
import { TextInputField } from '@luke-ui/react/text-input-field';
import type { SubmitEvent } from 'react';

const reservedUsernames = new Set(['admin', 'root', 'support']);

function validateUsername(value: string): string | null {
	if (reservedUsernames.has(value.trim().toLowerCase())) {
		return 'That username is reserved. Choose another.';
	}

	return null;
}

function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
	event.preventDefault();
}

export default () => {
	return (
		<Form onSubmit={handleSubmit}>
			<Stack gap="space.16" style={{ maxInlineSize: '20rem' }}>
				<TextInputField
					defaultValue="admin"
					label="Username"
					name="username"
					validate={validateUsername}
				/>
				<Cluster>
					<Button type="submit">Create account</Button>
				</Cluster>
			</Stack>
		</Form>
	);
};

Return one actionable message, even when several conditions could fail. Check the most important condition first so the next message appears once that one is fixed. The field joins multiple messages with spaces rather than rendering a list.

Realtime validation

Pass a controlled errorMessage for feedback that must update on every keystroke, such as password strength. You decide validity here, not the field. Make the field controlled with value and onChange, and compute errorMessage from that value on every render. An absent message keeps the field valid, so undefined, null, false, and an empty string all mean no error. The message appears when the application supplies a non-empty value and clears when that message is removed.

const [password, setPassword] = useState('');
const isTooShort = password.length > 0 && password.length < 12;

<TextInputField
	errorMessage={isTooShort ? 'Passwords need at least 12 characters.' : undefined}
	label="Password"
	onChange={setPassword}
	type="password"
	value={password}
/>;

Form libraries

Pass the library's current error message as errorMessage. The field goes invalid as soon as the library reports one, and clears once the library clears it. Set validationBehavior="aria" on Form so native validation does not block the library's submit handler.

Read React Hook Form and TanStack Form for the full setup.

Server validation

Pass server errors through Luke UI's Form as validationErrors, keyed by each field's name. Server errors appear immediately. Form clears a field's server error when the value is committed, for example when the field blurs after an edit.

A React Action can return those errors with the submitted values. Pass the Action to Form's action, feed validationErrors from useActionState, and restore each field with defaultValue so values survive an unsuccessful response. After a successful Action, React resets uncontrolled fields. Pass the pending state to the submit Button.

Validation: Server errors

import { Button } from '@luke-ui/react/button';
import { Cluster } from '@luke-ui/react/cluster';
import { Form } from '@luke-ui/react/form';
import { Stack } from '@luke-ui/react/stack';
import { Text } from '@luke-ui/react/text';
import { TextInputField } from '@luke-ui/react/text-input-field';
import { useActionState } from 'react';

type FormState = {
	errors?: { username?: string };
	values?: { username?: string };
};

const takenUsernames = new Set(['admin', 'root', 'support']);

export default () => {
	const [state, formAction, isPending] = useActionState(createAccount, {});
	const isSuccess = Boolean(state.values?.username && !state.errors);

	return (
		<Stack gap="space.16" style={{ maxInlineSize: '20rem' }}>
			<Form action={formAction} necessityIndicator="required-label" validationErrors={state.errors}>
				<Stack gap="space.16">
					<TextInputField
						defaultValue={state.values?.username}
						isRequired
						label="Username"
						name="username"
					/>
					<Cluster>
						<Button isPending={isPending} type="submit">
							Create account
						</Button>
					</Cluster>
				</Stack>
			</Form>
			<Text elementType="p" isVisuallyHidden={!isSuccess} role="status">
				{isSuccess ? `Created account for ${state.values?.username}.` : null}
			</Text>
		</Stack>
	);
};

async function createAccount(_previous: FormState, formData: FormData): Promise<FormState> {
	await new Promise((resolve) => {
		setTimeout(resolve, 400);
	});

	const usernameValue = formData.get('username');
	const username = typeof usernameValue === 'string' ? usernameValue : '';
	const values = { username };

	if (takenUsernames.has(username.trim().toLowerCase())) {
		return {
			errors: { username: 'That username is taken. Choose another.' },
			values,
		};
	}

	return { values };
}

Try the username admin to see a server error. Change the value and leave the field to clear it, then choose another name to submit successfully.

A non-empty field-level errorMessage overrides that field's entry in validationErrors. Clearing the field error reveals the outstanding server error if the value has not been committed. Committing a new value also clears that field's server error.

Focus after validation

With native validation, an unsuccessful submission focuses the first invalid field. Keep that default when each field's message is enough to fix the problem.

Override it when the form shows an error summary, or when focus should land somewhere else first. Pass onInvalid on Form and call preventDefault to stop that focus move, then move focus yourself. See React Aria's Form focus management for an error-summary pattern.

Server errors from validationErrors appear after the Action returns. They do not use that invalid-event focus path. Form libraries often manage focus themselves. Read React Hook Form when that library owns validation.

Always pair an invalid field with a message

An error message reports the problem when someone cannot distinguish the danger border from the resting border. It shows an error icon before its text.

Give every invalid field a message, as WCAG 1.4.1 Use of Colour requires. Let a constraint or validate generate the message, or pass your own through errorMessage. The primitive roots take isInvalid directly, so pair that state with a message yourself.

Write the message

An error message has two jobs: name the problem, and name the fix.

errorMessage takes any React content, so a message with a link or emphasis works directly.

Instead ofWrite
Invalid inputEnter a valid email address.
This field is requiredChoose a country.
ErrorPasswords need at least 12 characters.
  • Say what to do next, not only what went wrong. "Choose a delivery date" beats "No date selected".
  • Use the field's own words. If the label says "Country", the message says country, not region.
  • Keep it to one sentence. A long message pushes the rest of the form down the page.
  • Describe what is wrong with the value. Do not blame the reader. "Enter a date after today" rather than "You entered an invalid date".
  • Repeat anything someone needs from description. They may never look back up.

Continue learning