はじめに
Next.js app routerのチュートリアルの第14章のアウトプットをします。
前の記事
【01】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/af58da3d20cbc790e767
【02】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/edf450b3ee135e83d1e8
【03】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/612221eac233aa9cbb74
【04】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/62f9beccbfe36eaf7f90
【05】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/8b71b1d1df7c9435a9c9
【06】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/58130c3cfbaf8a573de2
【07】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/2c2da0f8071e60454679
【08】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/45f45fcb9cc14506f79f
【09】Next.js app routerのチュートリアルやってみる(loading.tsxとSuspenseでストリーミング)
https://qiita.com/naoyuki2/items/717694288ec6017a3af2
【10】Next.js app routerのチュートリアルやってみる(部分的な事前レンダリング)
https://qiita.com/naoyuki2/items/8062f755b0679fe925b1
【11-1】Next.js app routerのチュートリアルやってみる(URLパラメーターを利用した検索機能)
https://qiita.com/naoyuki2/items/2be9503ac80fc4a1fa6a
【11-2】Next.js app routerのチュートリアルやってみる(URL パラメータを利用したページネーション)
https://qiita.com/naoyuki2/items/fd00dc2b376e7d87fb44
【12-1】Next.js app routerのチュートリアルやってみる(React Server Actionsを使ったデータ作成処理)
https://qiita.com/naoyuki2/items/04ffef203ae798f8c7bc
【12-2】Next.js app routerのチュートリアルやってみる(React Server Actionsを使ったデータ更新処理)
https://qiita.com/naoyuki2/items/41f16ef69a50171d9d86
【12-3】Next.js app routerのチュートリアルやってみる(React Server Actionsを使ったデータ削除処理)
https://qiita.com/naoyuki2/items/487239ef9c54c5ef7875
【13】Next.js app routerのチュートリアルやってみる(エラーハンドリング error.tsx not-found.tsx)
第14章 アクセシビリティの向上
前回は、エラーが起きた際、ユーザーにフォールバックUIを表示させる方法について勉強しました。
そして今回は、サーバーアクションを用いて、フォームの検証をしていきます。
- useFormStateの使い方
- サーバー側でフォームを検証する方法
サーバー側でフォームを検証
現在は、フォームの入力要素が空の状態で送信してしまうとエラーが発生してしまいます。
これを検証するためにcreate-form.tsx
でuseFormState
というフックを使用します。
useFormStateとは
useFormState は、フォームアクションの結果に基づいて state を更新するためのフックです。
準備
react-dom
からuseFormState
をimport
します。
クライアントコンポーネントでしか動作しないため、use client
をつける。
+'use client';
// ...
+import { useFormState } from 'react-dom';
useFormState
は2つの引数を受け取ります。
- 第1引数には、フォーム送信時に発火させる関数を入れる
- 今回はフォーム送信時に
createInvoice
を発火させる
- 今回はフォーム送信時に
- 第2引数には
state
の初期値を入れる- 今回は
message
とerrors
を持ったinitialState
というオブジェクトを入れる
- 今回は
useFormState
は2つの戻り値があります。
- 1つめの戻り値は、要素の現在の状態を受け取る
- 初回レンダリング時は
initialState
が入っている
- 初回レンダリング時は
- 2つめの戻り値は、フォームアクションをうけとる
- これを
<form>
タグのaction
属性にいれる
- これを
'use client'
import { useFormState } from 'react-dom'
+import { createInvoice } from '@/app/lib/action'
export default function Form({ customers }: { customers: CustomerField[] }) {
+ const initialState = {
+ message: null,
+ errors: {},
+ }
+ const [state, dispatch] = useFormState(createInvoice, initialState)
+ return <form action={dispatch}>...</form>;
}
これによってフォーム送信時にdispatch
(=createInvoice
)が発火します。
その際に現在のstate
とformDate
を渡すようになりました。
サーバーアクション側の実装
前回の記事でZod
というライブラリを使ってフォームデータを検証しました。
その中にエラーメッセージの記述を追加しておきましょう。
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: '顧客を選択してください。',
}),
amount: z.coerce
.number()
.gt(0, { message: '0より大きい金額を入力してください。' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'pending または paid を選択してください。',
}),
date: z.string(),
});
このようにすることで、parse
メソッドで型を検証する際に
customerId
はstring
を期待しているのに、number
型のものが入っていたら、エラーを投げるようになりました。
続いてフォームアクションからstate
受け取るように変更し、その型を定義しましょう。
+export type State = {
+ errors?: {
+ customerId?: string[];
+ amount?: string[];
+ status?: string[];
+ };
+ message?: string | null;
+};
+export async function createInvoice(prevState: State, formData: FormData) {
// ...
}
エラーメッセージはバリデーション成功時には存在しないため、型はオプショナル?(任意)にしておきましょう。
createInvoice
が受け取る引数にprevState
というのを追加しましょう。
今回の実装では使用しませんが、必須のprops
なのでしかたなく受け取ります。
続いて、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'),
});
// ...
}
なぜsafeParse()
にするのかというと、
parse()
は型の検証に失敗した場合にエラーをスローします。
そのため、try/catch
で管理しないとアプリがクラッシュしてしまいます。
一方、safeParse()
は常にオブジェクトで結果を返します。
成功か否かのsuccess
プロパティと、
成功の場合はフォームデータを、
失敗の場合はエラー内容を、格納しています。
safeParse
の方がエラーハンドリングが楽なので使用するみたいです。
{
success: true,
data: {
customerId: '3958dc9e-712f-4377-85e9-fec4b6a6442a',
amount: 235,
status: 'pending'
}
}
{ success: false, error: [Getter] }
続いて、バリデーション失敗時にエラーメッセージを格納していきます。
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 (!validatedFields.success) {
+ return {
+ errors: validatedFields.error.flatten().fieldErrors,
+ message: 'Missing Fields. Failed to Create Invoice.',
+ };
+ }
// ...
}
条件分岐に!validatedFileds.success
とすることで、success
がfalse
の場合だけエラーメッセージを格納するようにします。
zod
ライブラリのZodError
オブジェクトには、flatten
というメソッドがあります。このメソッドは、エラーオブジェクトをより扱いやすい形式に変換してくれます。
flatten
メソッドを使用すると、ZodError
オブジェクトは以下のような形式のオブジェクトに変換されます。
{
formErrors: [],
fieldErrors: {
customerId: [ '顧客を選択してください。' ],
amount: [ '0より大きい金額を入力してください。' ],
status: [ 'pending または paid を選択してください。' ]
}
}
formErrorsについて補足
formErrors
オブジェクトは、フォーム全体に関連するエラーメッセージを格納します。
これは、特定のフィールドではなく、フォーム全体に適用されるバリデーションルールがある場合に使用されます。
例えば、複数のフィールドを組み合わせたバリデーション(パスワードと確認用パスワードが一致することを確認するなど)がある場合などです。
これによってvalidatedFields.error.flatten().fieldErrors
という記述で、state
にエラーメッセージを格納できるというわけです。
言い忘れてましたが、フォームアクションで実行された関数のreturn
の値によってstate
が更新されます。
これでサーバー側は完了です。
actions.tsの最終的なコード
export async function createInvoice(prevState: State, formData: FormData) {
// Validate form 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.',
};
}
// Prepare data for insertion into the database
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// Insert data into the database
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
// If a database error occurs, return a more specific error.
return {
message: 'Database Error: Failed to Create Invoice.',
};
}
// Revalidate the cache for the invoices page and redirect the user.
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
エラーメッセージを表示しよう
サーバーアクションが返したreturn
によって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>
{customers.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.erros?.customerId
の中身があればエラーメッセージを表示するようにしています。
そして、エラーメッセージは配列で格納されているためmap
を使用して展開しています。
また、aria-describedby="customer-error"
を<select>
に記述することでスクリーンリーダーが適切に読み上げてくれます。
おわりに
だんだんサーバーアクションが分かってきた気がする。
参考
次の記事