Form validation
Form validation (폼 검증)
useForm에서 폼 검증을 구성하는 다양한 방법을 다뤄요. 객체 필드 조합 검증, 다른 값 기반 검증, 함수 기반 검증, 변경/blur 시 검증, 비동기 검증, 디바운스 등 검증 관련 기능을 하나씩 설명해 드릴게요.
출처: 문서
본문
객체 필드 조합 검증
객체 필드의 조합을 검증하는 예시예요. formRootRule을 사용하면 객체 자체에 규칙을 적용할 수 있어요:
import { Button, Text, TextInput } from '@mantine/core';
import { formRootRule, isNotEmpty, useForm } from '@mantine/form';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
initialValues: {
user: {
firstName: '',
lastName: '',
},
},
validate: {
user: {
[formRootRule]: (value) =>
value.firstName.trim().length > 0 && value.firstName === value.lastName
? 'First name and last name cannot be the same'
: null,
firstName: isNotEmpty('First name is required'),
lastName: isNotEmpty('Last name is required'),
},
},
});
return (
<form onSubmit={form.onSubmit(() => {})}>
<TextInput
label="First name"
placeholder="First name"
{...form.getInputProps('user.firstName')}
/>
<TextInput
label="Last name"
placeholder="Last name"
mt="md"
{...form.getInputProps('user.lastName')}
/>
{form.errors.user && (
<Text c="red" mt={5} fz="sm">
{form.errors.user}
</Text>
)}
<Button type="submit" mt="lg">
Submit
</Button>
</form>
);
}
다른 폼 값에 기반한 검증
검증 규칙 함수의 두 번째 인자로 모든 폼 값을 받아, 다른 폼 값에 기반해 필드를 검증할 수 있어요. 예를 들어 비밀번호 확인이 비밀번호와 같은지 검증할 수 있어요:
import { useForm } from '@mantine/form';
import { PasswordInput, Group, Button, Box } from '@mantine/core';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
initialValues: {
password: 'secret',
confirmPassword: 'sevret',
},
validate: {
confirmPassword: (value, values) =>
value !== values.password ? 'Passwords did not match' : null,
},
});
return (
<Box maw={340} mx="auto">
<form onSubmit={form.onSubmit((values) => console.log(values))}>
<PasswordInput
label="Password"
placeholder="Password"
key={form.key('password')}
{...form.getInputProps('password')}
/>
<PasswordInput
mt="sm"
label="Confirm password"
placeholder="Confirm password"
key={form.key('confirmPassword')}
{...form.getInputProps('confirmPassword')}
/>
<Group justify="flex-end" mt="md">
<Button type="submit">Submit</Button>
</Group>
</form>
</Box>
);
}
함수 기반 검증
검증을 처리하는 또 다른 방법은 validate에 함수를 제공하는 거예요. 이 함수는 폼 값을 단일 인자로 받고, 해당 필드들의 오류를 담은 객체를 반환해야 해요. 필드가 유효하거나 필드 검증이 필요 없다면 null을 반환하거나 검증 결과에서 그냥 생략하면 돼요.
import { useForm } from '@mantine/form';
import { Box, TextInput, NumberInput, Button, Group } from '@mantine/core';
function Demo() {
const form = useForm<{ name: string; age: number | undefined }>({
mode: 'uncontrolled',
initialValues: { name: '', age: undefined },
validate: (values) => ({
name: values.name.length < 2 ? 'Too short name' : null,
age:
values.age === undefined
? 'Age is required'
: values.age < 18
? 'You must be at least 18'
: null,
}),
});
return (
<Box maw={340} mx="auto">
<form onSubmit={form.onSubmit((values) => console.log(values))}>
<TextInput
label="Name"
placeholder="Name"
key={form.key('name')}
{...form.getInputProps('name')}
/>
<NumberInput
mt="sm"
label="Age"
placeholder="You age"
key={form.key('age')}
{...form.getInputProps('age')}
/>
<Group justify="flex-end" mt="md">
<Button type="submit">Submit</Button>
</Group>
</form>
</Box>
);
}
변경 시 필드 검증
변경 시 모든 필드를 검증하려면 validateInputOnChange 옵션을 true로 설정해요:
import { useForm } from '@mantine/form';
const form = useForm({
mode: 'uncontrolled',
validateInputOnChange: true,
});
import { useForm } from '@mantine/form';
import { NumberInput, TextInput, Button, } from '@mantine/core';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
validateInputOnChange: true,
initialValues: { name: '', email: '', age: 0 },
// functions will be used to validate values at corresponding key
validate: {
name: (value) => (value.length < 2 ? 'Name must have at least 2 letters' : null),
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
age: (value) => (value < 18 ? 'You must be at least 18 to register' : null),
},
});
return (
<form onSubmit={form.onSubmit(console.log)}>
<TextInput
label="Name"
placeholder="Name"
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
mt="sm"
label="Email"
placeholder="Email"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<NumberInput
mt="sm"
label="Age"
placeholder="Age"
min={0}
max={99}
key={form.key('age')}
{...form.getInputProps('age')}
/>
<Button type="submit" mt="sm">
Submit
</Button>
</form>
);
}
특정 필드만 검증하려면 필드 경로 배열을 제공할 수도 있어요:
import { FORM_INDEX, useForm } from '@mantine/form';
const form = useForm({
mode: 'uncontrolled',
validateInputOnChange: [
'name',
'email',
`jobs.${FORM_INDEX}.title`,
],
});
import { useForm, FORM_INDEX } from '@mantine/form';
import { NumberInput, TextInput, Button } from '@mantine/core';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
validateInputOnChange: [
'email',
'name',
// use FORM_INDEX to reference fields indices
`jobs.${FORM_INDEX}.title`,
],
initialValues: { name: '', email: '', age: 0, jobs: [{ title: '' }, { title: '' }] },
// functions will be used to validate values at corresponding key
validate: {
name: (value) => (value.length < 2 ? 'Name must have at least 2 letters' : null),
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
age: (value) => (value < 18 ? 'You must be at least 18 to register' : null),
jobs: {
title: (value) => (value.length < 2 ? 'Job must have at least 2 letters' : null),
},
},
});
return (
<form onSubmit={form.onSubmit(console.log)}>
<TextInput
label="Name"
placeholder="Name"
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
mt="sm"
label="Email"
placeholder="Email"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<NumberInput
mt="sm"
label="Age"
placeholder="Age"
min={0}
max={99}
key={form.key('age')}
{...form.getInputProps('age')}
/>
<TextInput
mt="sm"
label="Job 1"
placeholder="Job 1"
key={form.key('jobs.0.title')}
{...form.getInputProps('jobs.0.title')}
/>
<TextInput
mt="sm"
label="Job 2"
placeholder="Job 2"
key={form.key('jobs.1.title')}
{...form.getInputProps('jobs.1.title')}
/>
<Button type="submit" mt="sm">
Submit
</Button>
</form>
);
}
blur 시 필드 검증
blur 시 모든 필드를 검증하려면 validateInputOnBlur 옵션을 true로 설정해요:
import { useForm } from '@mantine/form';
const form = useForm({
mode: 'uncontrolled',
validateInputOnBlur: true,
});
import { useForm } from '@mantine/form';
import { NumberInput, TextInput, Button } from '@mantine/core';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
validateInputOnBlur: true,
initialValues: { name: '', email: '', age: 0 },
// functions will be used to validate values at corresponding key
validate: {
name: (value) => (value.length < 2 ? 'Name must have at least 2 letters' : null),
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
age: (value) => (value < 18 ? 'You must be at least 18 to register' : null),
},
});
return (
<form onSubmit={form.onSubmit(console.log)}>
<TextInput
label="Name"
placeholder="Name"
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
mt="sm"
label="Email"
placeholder="Email"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<NumberInput
mt="sm"
label="Age"
placeholder="Age"
min={0}
max={99}
key={form.key('age')}
{...form.getInputProps('age')}
/>
<Button type="submit" mt="sm">
Submit
</Button>
</form>
);
}
특정 필드만 검증하려면 필드 경로 배열을 제공할 수도 있어요:
import { FORM_INDEX, useForm } from '@mantine/form';
const form = useForm({
mode: 'uncontrolled',
validateInputOnBlur: ['name', 'email', `jobs.${FORM_INDEX}.title`],
});
import { useForm, FORM_INDEX } from '@mantine/form';
import { NumberInput, TextInput, Button } from '@mantine/core';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
validateInputOnBlur: [
'email',
'name',
// use FORM_INDEX to reference fields indices
`jobs.${FORM_INDEX}.title`,
],
initialValues: { name: '', email: '', age: 0, jobs: [{ title: '' }, { title: '' }] },
// functions will be used to validate values at corresponding key
validate: {
name: (value) => (value.length < 2 ? 'Name must have at least 2 letters' : null),
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
age: (value) => (value < 18 ? 'You must be at least 18 to register' : null),
jobs: {
title: (value) => (value.length < 2 ? 'Job must have at least 2 letters' : null),
},
},
});
return (
<form onSubmit={form.onSubmit(console.log)}>
<TextInput
label="Name"
placeholder="Name"
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
mt="sm"
label="Email"
placeholder="Email"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<NumberInput
mt="sm"
label="Age"
placeholder="Age"
min={0}
max={99}
key={form.key('age')}
{...form.getInputProps('age')}
/>
<TextInput
mt="sm"
label="Job 1"
placeholder="Job 1"
key={form.key('jobs.0.title')}
{...form.getInputProps('jobs.0.title')}
/>
<TextInput
mt="sm"
label="Job 2"
placeholder="Job 2"
key={form.key('jobs.1.title')}
{...form.getInputProps('jobs.1.title')}
/>
<Button type="submit" mt="sm">
Submit
</Button>
</form>
);
}
변경 시 필드 오류 지우기
기본적으로 값이 변경되면 필드 오류가 지워져요. 이를 바꾸려면 clearInputErrorOnChange를 false로 설정해요:
import { useForm } from '@mantine/form';
const form = useForm({
mode: 'uncontrolled',
clearInputErrorOnChange: false,
});
import { TextInput, Checkbox, Button, Group } from '@mantine/core';
import { useForm } from '@mantine/form';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
clearInputErrorOnChange: false,
initialValues: {
email: '',
termsOfService: false,
},
validate: {
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
},
});
return (
<form onSubmit={form.onSubmit((values) => console.log(values))}>
<TextInput
withAsterisk
label="Email"
placeholder="[email protected]"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<Checkbox
mt="md"
label="I agree to sell my privacy"
key={form.key('termsOfService')}
{...form.getInputProps('termsOfService', { type: 'checkbox' })}
/>
<Group justify="flex-end" mt="md">
<Button type="submit">Submit</Button>
</Group>
</form>
);
}
onSubmit 핸들러에서의 검증
form.onSubmit은 두 인자를 받아요. 첫 번째는 검증이 오류 없이 완료됐을 때 폼 값과 함께 호출되는 handleSubmit 함수, 두 번째는 검증이 오류와 함께 완료됐을 때 오류 객체와 함께 호출되는 handleErrors 함수예요.
handleErrors 함수를 이용해 사용자가 값을 입력하지 않고 폼을 제출하려 할 때 특정 동작을 수행할 수 있어요. 예를 들어 알림(notification)을 표시할 수 있어요:
import { useForm } from '@mantine/form';
import { TextInput, Button } from '@mantine/core';
import { notifications } from '@mantine/notifications';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
initialValues: { name: '', email: '' },
validate: {
name: (value) => (value.length < 2 ? 'Name must have at least 2 letters' : null),
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
},
});
const handleError = (errors: typeof form.errors) => {
if (errors.name) {
notifications.show({ message: 'Please fill name field', color: 'red' });
} else if (errors.email) {
notifications.show({ message: 'Please provide a valid email', color: 'red' });
}
};
return (
<form onSubmit={form.onSubmit(console.log, handleError)}>
<TextInput
label="Name"
placeholder="Name"
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
mt="sm"
label="Email"
placeholder="Email"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<Button type="submit" mt="sm">
Submit
</Button>
</form>
);
}
isValid 핸들러
form.isValid는 주어진 검증 함수, 규칙 객체 또는 스키마로 폼 검증을 수행하지만, form.validate와 달리 form.errors를 설정하지 않고 폼이 유효한지 나타내는 boolean만 반환해요. 검증 규칙 중 하나라도 비동기면 form.isValid는 대신 Promise를 반환해요.
import { useForm } from '@mantine/form';
const form = useForm({
mode: 'uncontrolled',
initialValues: { name: '', age: 0 },
validate: {
name: (value) => (value.trim().length < 2 ? 'Too short' : null),
age: (value) => (value < 18 ? 'Too young' : null),
},
});
// With sync rules, returns boolean directly
form.isValid(); // -> false
form.isValid('name'); // -> false
비동기 검증
검증 규칙은 비동기일 수 있어요. 오류 메시지나 null로 resolve되는 Promise를 반환하면 돼요. 모든 규칙이 동기일 때 form.validate(), form.validateField(), form.isValid()는 결과를 직접 반환해요(Promise로 감싸지 않음). 어떤 규칙이라도 비동기면 이 메서드들은 Promise를 반환해요. TypeScript가 검증 규칙에 기반해 올바른 반환 타입을 추론하므로 수동 주석 없이도 정확한 타입을 얻을 수 있어요.
form.validating 속성은 비동기 검증이 진행되는 동안 true이고, form.isValidating(path)로 개별 필드를 확인할 수 있어요. 동기 규칙만 있는 폼에서는 validating 상태가 절대 설정되지 않아요.
각 규칙은 네 번째 인자로 AbortSignal을 받아요. 더 새로운 검증이 현재 검증을 대체하면 시그널이 abort되는데, 이를 이용해 진행 중인 HTTP 요청을 취소하고 경쟁 조건을 피할 수 있어요.
import { Button, Group, Loader, TextInput } from '@mantine/core';
import { isEmail, useForm } from '@mantine/form';
// Simulates an async API call to check if the username is available
function checkUsernameAvailability(username: string, signal?: AbortSignal): Promise<string | null> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const taken = ['admin', 'user', 'test', 'mantine'];
resolve(taken.includes(username.toLowerCase()) ? 'Username is already taken' : null);
}, 800);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
function Demo() {
const form = useForm({
mode: 'uncontrolled',
initialValues: { username: '', email: '' },
validate: {
username: async (value, _values, _path, signal) => {
if (value.trim().length < 3) {
return 'Username must be at least 3 characters';
}
return checkUsernameAvailability(value, signal);
},
email: isEmail('Invalid email'),
},
});
return (
<form onSubmit={form.onSubmit((values) => console.log(values))}>
<TextInput
withAsterisk
label="Username"
placeholder="Pick a username"
key={form.key('username')}
disabled={form.submitting}
rightSection={form.validating ? <Loader size={16} /> : null}
{...form.getInputProps('username')}
/>
<TextInput
withAsterisk
mt="md"
label="Email"
placeholder="[email protected]"
key={form.key('email')}
disabled={form.submitting}
{...form.getInputProps('email')}
/>
<Group justify="flex-end" mt="md">
<Button type="submit" loading={form.submitting}>
Submit
</Button>
</Group>
</form>
);
}
디바운스가 적용된 비동기 검증
validateInputOnChange와 함께 비동기 검증을 쓸 때 validateDebounce를 설정하면 키 입력마다 API 호출이 발생하는 것을 피할 수 있어요. 디바운스는 validateInputOnChange와 validateInputOnBlur가 유발하는 필드 레벨 검증에만 적용되고, 명시적인 form.validate() 호출이나 form.onSubmit()에는 영향을 주지 않아요.
import { Button, Group, Loader, TextInput } from '@mantine/core';
import { useForm, isEmail } from '@mantine/form';
// Simulates an async API call to check if the username is available
function checkUsernameAvailability(username: string, signal?: AbortSignal): Promise<string | null> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const taken = ['admin', 'user', 'test', 'mantine'];
resolve(taken.includes(username.toLowerCase()) ? 'Username is already taken' : null);
}, 800);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
function Demo() {
const form = useForm({
mode: 'uncontrolled',
initialValues: { username: '', email: '' },
// Debounce async validation by 500ms – prevents firing
// an API call on every keystroke
validateDebounce: 500,
validateInputOnChange: ['username'],
validate: {
username: async (value, _values, _path, signal) => {
if (value.trim().length < 3) {
return 'Username must be at least 3 characters';
}
return checkUsernameAvailability(value, signal);
},
email: isEmail('Invalid email'),
},
});
return (
<form onSubmit={form.onSubmit((values) => console.log(values))}>
<TextInput
withAsterisk
label="Username"
description="Try: admin, user, test, mantine"
placeholder="Pick a username"
key={form.key('username')}
disabled={form.submitting}
rightSection={form.isValidating('username') ? <Loader size={16} /> : null}
{...form.getInputProps('username')}
/>
<TextInput
withAsterisk
mt="md"
label="Email"
placeholder="[email protected]"
key={form.key('email')}
disabled={form.submitting}
{...form.getInputProps('email')}
/>
<Group justify="flex-end" mt="md">
<Button type="submit" loading={form.submitting}>
Submit
</Button>
</Group>
</form>
);
}
첫 번째 유효하지 않은 필드에 포커스하기
form.onSubmit 함수의 두 번째 인자는 폼 검증이 실패했을 때 errors 객체와 함께 호출되는 콜백 함수예요. 이 콜백을 사용해 첫 번째 유효하지 않은 필드에 포커스하거나 다른 작업을 수행할 수 있어요.
어떤 입력의 DOM 노드를 가져오려면 form.getInputNode('path-to-field')를 사용해요. 이 기능이 동작하려면 form.getInputProps('path-to-field')를 입력 요소에 스프레드해야 해요.
import { Button, Group, TextInput } from '@mantine/core';
import { isEmail, isNotEmpty, useForm } from '@mantine/form';
function Demo() {
const form = useForm({
mode: 'uncontrolled',
name: 'register-form',
initialValues: {
name: '',
email: '',
},
validate: {
name: isNotEmpty('Name is required'),
email: isEmail('Invalid email'),
},
});
return (
<form
onSubmit={form.onSubmit(
(values) => console.log(values),
(errors) => {
const firstErrorPath = Object.keys(errors)[0];
form.getInputNode(firstErrorPath)?.focus();
}
)}
>
<TextInput
withAsterisk
label="Your name"
placeholder="Your name"
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
withAsterisk
label="Your email"
placeholder="[email protected]"
mt="md"
key={form.key('email')}
{...form.getInputProps('email')}
/>
<Group justify="flex-end" mt="md">
<Button type="submit">Submit</Button>
</Group>
</form>
);
}
더 알아보기 (Learn more)
- Form errors — 폼 오류
- Form schema validation — 폼 스키마 검증