클라이언트 단에서 form의 유효성을 검증하는 방법은 여러가지가 있다. 가장 간단한 방법은 <input>
과 <select>
태그에 required
속성을 추가하는 것이다.
<input
id="amount"
name="amount"
type="number"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
required
/>
이렇게하면 빈 input을 제출하려고 할 때 브라우저에서 오류를 보여준다.
서버에서 form을 검증함으로써 다음과 같은 효과를 얻을 수 있다.
'use client';
// ...
import { useFormState } from 'react-dom';
useFormState
는 훅이기 때문에 'use client'
지시문으로 클라이언트 컴포넌트임을 명시해주어야한다.
Form 컴포넌트 내부에서, useFormState
훅은
(action, initialState)
[state, dispatch]
- form state 와 dispatch 함수이다. (React의 useReducer 훅과 유사하다.)기존의 action을 useFormState
의 인자로 넘겨주고, form의 action 속성에는 dispatch
함수를 전달해주자.
// ...
import { useFormState } from 'react-dom';
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState = { message: null, errors: {} };
const [state, dispatch] = useFormState(createInvoice, initialState);
return <form action={dispatch}>...</form>;
}
initialState
는 어떤 것도 될 수 있다.
Server Action쪽 코드에 작성되어 있던 FormSchema
를 수정해보자.
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.',
}),
amount: z.coerce
.number()
.gt(0, { message: 'Please enter an amount greater than $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(),
});
이제 Zod는 각 타입과 맞는지 검사해 맞지 않는다면 우리가 설정한 메세지를 리턴해줄 것이다. 이 메세지를 활용해 사용자에게 잘못되었음을 알려주자.
// This is temporary until @types/react-dom is updated
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {
// ...
}
먼저 createInvoice 함수도 수정을 해주어야한다.
formData
- 이전과 동일prevState
- useFormState
훅으로부터 전달된 state이다.Zod의 parse()
부분도 safeParse()
로 변경해주어야한다.
export async function createInvoice(prevState: State, formData: FormData) {
// Validate form fields using Zod
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// If form validation fails, return errors early. Otherwise, continue.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
}
// ...
}
parse()
는 검증이 실패했을 때 오류를 throw 하지만, safeParse()
는 성공 또는 실패를 포함한 객체를 리턴해준다. 이를 활용하여 오류 발생 시 유저에게 UI를 통해 잘못되었음을 알려줄 수 있다.
이제 다시 form으로 돌아와서 state
를 통해 오류를 감지하고 사용자에게 알려주자.
<form action={dispatch}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Choose customer
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
aria-describedby="customer-error"
>
<option value="" disabled>
Select a customer
</option>
{customerNames.map((name) => (
<option key={name.id} value={name.id}>
{name.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
// ...
</div>
</form>
state와 삼항 연산자를 활용해 사용자에게 오류를 보여주는 것을 확인할 수 있다.