前回
Next.jsのチュートリアルをやってみる【エラー処理編】はじめに
前回はエラーをキャッチし、ユーザーにフォールバックUIを表示する方法について学びました。ですが、フォームバリデーションについてはまだ学ぶべき内容があります。
今回はサーバーアクションを使用してサーバーサイドのバリデーションを実装する方法と、ReactのuseActionState
フックを使用してフォームエラーを表示する方法を、アクセシビリティに考慮しながら学んでいきます。
アクセシビリティとは?
そもそもアクセシビリティとはというところから説明していきます。
アクセシビリティとは、障害のある人を含め、だれもが使用できるウェブアプリケーションを設計し実装することを指します。キーボードナビゲーション、セマンティックHTML、画像、色、動画など、多くの分野をカバーする必要があるトピックです。
チュートリアルではアクセシビリティについて深く掘り下げてはいませんが、Next.jsで利用可能な機能と、アプリケーションをより利用しやすくするための一般的なパターンについて説明していきます。
アクセシビリティについて深堀りしたい方は、Next.jsがお勧めしているweb.devが公開しているLearn Accessibilityを確認してください。
Next.jsでESLintアクセシビリティプラグインを使用する
Next.jsのESLint設定には、アクセシビリティの問題を早期に発見するためのeslint-plugin-jsx-a11yプラグインが含まれています。たとえば、alt
テキストがない画像、aria-*
属性やrole
属性の間違った使い方などを警告してくれます。
試したい方はpackage.json
ファイルにスクリプトとしてnext-lint
を追加してください。
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"lint": "next lint"
},
pnpm lint
を実行すると以下の内容が出力されるます。
Base
がコードの正確性のための推奨ルール、Strict
は推奨ルールに加えて、より意見の分かれるルールを含め、バグの検出も可能にするモードとなります。
今回はNext.jsのチュートリアルに合わせるため、下方向キー・Enterキーの順に押してBase
を選択し、インストールしましょう。
? How would you like to configure ESLint? https://nextjs.org/docs/basic-features/eslint
❯ Strict (recommended)
Base
Cancel
インストールが完了したら、再度pnpm lint
を実行してみましょう。以下の様に警告とエラーがない旨が出力されます。
> next lint
✔ No ESLint warnings or errors
しかし、alt
属性を消したらどうなるでしょうか。/app/ui/invoices/table.tsx
の<Image>
からalt
属性を一時的に消して、再度実行し確認してみましょう。すると、以下の様に警告が出力されるようになります。
./app/ui/invoices/table.tsx
88:23 Warning: Image elements must have an alt prop, either with meaningful text, or an empty string for decorative images. jsx-a11y/alt-text
フォームのアクセシビリティの向上
実はこれまでのチュートリアルでフォームのアクセシビリティを向上させるために以下のようなことを行っていました。
項目 | 内容 |
---|---|
セマンティック HTML | <div> の代わりにセマンティック要素である<input> 、<option >などを使う。これによりATと呼ばれる支援技術は入力要素に焦点をあて、適切な文脈情報をユーザーに提供し、フォームをナビゲートし理解しやすくすることができます。 |
ラベリング | <label> とhtmlFor 属性を含めることで、各フォーム・フィールドが説明的なテキストとラベルを持つようになります。これは文脈を提供することで、ATサポートを向上させ、ユーザーがラベルをクリックすることで対応する入力フィールドにフォーカスできるようにすることで、ユーザビリティを向上させます。 |
フォーカス アウトライン | フィールドがフォーカスされたときにアウトラインが表示されるように適切にスタイルされます。これはアクセシビリティにとって重要で、ページ上でアクティブな要素を視覚的に示すので、キーボードとスクリーンリーダーの両方のユーザーがフォームのどこにいるのかを理解するのに役立ちます。Tab キーを押すことで確認することができます。 |
これらはフォームを多くのユーザーにとって使いやすいものにするためにいい土台となります。ですがフォームのバリデーションやエラーには対応していません。
フォームバリデーション
http://localhost:3000/dashboard/invoices/createにアクセスして、空のフォームを送信してみましょう。
エラーが発生したと思います。これはフォームの値を空にしてサーバーアクションに送信しているからです。クライアント側またはサーバー側でフォームのバリデーションを行うことで、防ぐことができます。
クライアント側のバリデーション
クライアント側でフォームのバリデーションを行う方法はいくつかありますが、一番簡単なのは<input>
要素と<select>
要素にrequired
属性を追加して、ブラウザが提供するフォームバリデーションに頼ることです。
<select
id="customer"
name="customerId"
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
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
/>
もう一度空の値でフォームを送信してみましょう。そうするとブラウザが警告を表示すると思います。
ATの中にはブラウザのバリデーションをサポートしているものもあるので、この方法は基本的には問題ありません。クライアント側のバリデーションに代わるものとして、サーバー側のバリデーションがあります。
サーバー側のバリデーション
サーバー上でバリデーションを行うことで以下のようなメリットがあります。
- データをデータベースに送信する前にデータが期待される形式であることを確認する。
- 悪意のあるユーザーがクライアント側のバリデーションを回避するリスクを減らす。
- 有効なデータとみなされる情報源を一つにする。
まず初めにcreate-form.tsx
コンポーネントで、react
のuseActionState
フックをインポートしましょう。useActionState
はフックなので'use client'
ディレクティブを使用して、フォームをクライアントコンポーネントにする必要があります。
'use client';
// ...
import { useActionState } from 'react';
フォームコンポーネントの中のuseActionState
フックを確認すると、以下のような形式になっています。
action
とinitialiState
の二つの引数をとる。[state, formAction]
を戻り値とする。
フォームの状態とフォームが送信されたときに呼び出される関数を表す。
createInvoice
アクションをuseActionState
の第一引数として渡して、<form action={}>
属性内でformAction
を呼び出します。この際、第二引数のinitialStateはまだ用意していないので、同じ引数名を仮で入れておきます。
// ...
import { useActionState } from 'react';
export default function Form({ customers }: { customers: CustomerField[] }) {
const [state, formAction] = useActionState(createInvoice, initialState);
return <form action={formAction}>...</form>;
}
続いて第二引数のinitialState
を用意していきます。この場合、message
とerror
という二つの空のキーを持つオブジェクトを作成し、サーバー上でバリデーションを行うためactions.ts
ファイルからState
型をインポートします。
// ...
import { createInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState: State = { message: null, errors: {} };
const [state, formAction] = useActionState(createInvoice, initialState);
return <form action={formAction}>...</form>;
}
action.ts
ファイルではZodを使用してフォームデータを検証することができます。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(),
});
ここではエラーを出す値の場合どのようなエラー内容を出すかを決めています。
customerId | Zodはすでに文字列を想定しているため、文字列が空の場合はエラーを投げます。 |
amount | 文字列から数値に強制しているので、文字列が空の場合はデフォルトで0になります。.gt() 関数を使用して、常に0 よりも大きい金額を表示するようにしましょう。 |
status | Zodは"pending" または"paid" を想定しているため、空の場合はエラーを投げます。ユーザーがステータスを選択しなかった場合のメッセージも追加しましょう。 |
続いて、createInvoice
アクションを更新してprevState
とformData
を受け取れるようにいたします。prevState
にはuseActionState
フックから渡された状態が含まれます。今回の例のアクションでは使用しませんが、必須のpropです。
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {
// ...
}
続いてZodのparse()
関数をsafeParse()
に変更しましょう。sageParse()
は成功フィールドもしくはエラーフィールドを含むオブジェクトを返します。これにより、try/catch
ブロックの中にこのロジックを置くことなく、より適切にバリデーションを処理することができます。
export async function createInvoice(prevState: State, formData: FormData) {
// Zodを使用してフォームフィールドのバリデーションを行う
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// ...
}
そして、データベースに送信する前に、フォームフィールドが条件付きで正しくバリデーションされたかを確認します。validatedField
が成功しなかった場合は、Zod
からのエラーメッセージとともに早期リターンを行います。
export async function createInvoice(prevState: State, formData: FormData) {
// 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.',
};
}
// ...
}
最後に、try/catch
ブロックの外でフォームのバリデーションを個別に処理しているので、データベースエラーに対して特定のメッセージを返します。safeParse
に変更したのでcustomerId
, amount
, status
を取得できなくなってしまいました。代わりにvalidatedFields.data
で代入しましょう。
export async function createInvoice(prevState: State, formData: FormData) {
// 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.',
};
}
// データベースに挿入するデータを準備する
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// データベースにデータを挿入
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
return {
// データベースエラーが発生した場合は具体的なエラーメッセージを返す
message: 'Database Error: Failed to Create Invoice.',
};
}
// 請求書ページのキャッシュをバリデーションし、ユーザーをリダイレクトする
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
バリデーションの処理の追加が完了したので、フォームコンポーネントにエラーを表示してみましょう。
create-form.tsx
コンポーネントに戻って、フォームの状態を使用してエラーにアクセスすることができます。書く特定のエラーをチェックする三項演算子を追加しましょう。customer
フィールドに以下の処理を入れます。
<form action={formAction}>
<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>
aria-describedby="customer-error" | select 要素とエラー・メッセージ・コンテナとの関係が確立されます。id="customer-error" のコンテナがselect 要素を記述していることを示しています。スクリーン・リーダーはセレクトボックスにインタラクトする際にこの説明を読み、エラーを通知します。 |
id="customer-error" | このid 属性はselect 入力のエラーメッセージを保持するHTMLを識別します。aria-describedby が関係を確立するために必要になります。 |
aria-live="polite" | スクリーン・リーダーはdiv 内のエラーが更新されたときに、ユーザーに丁寧に通知する必要があります。コンテンツが変更されたとき(エラーを修正など)、スクリーン・リーダーは変更をユーザーに通知しますが、ユーザーの邪魔にならないようにユーザーがアイドル状態であるときに限ります。 |
練習:バリデーションを追加しよう
以上の内容で学んだことを使用して金額・状態・請求書更新フォームにバリデーションを追加してみましょう。以下内容は筆者作成のコードですが、事前に各自で行ってから確認することをお勧めいたします。
'use client';
import { CustomerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
export default function Form({
customers,
}: {
customers: CustomerField[]
}) {
const initialState: State = { message: null, errors: {} };
const [state, formAction] = useActionState(createInvoice, initialState);
return (
<form action={formAction}>
<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 cursor-pointer 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((customer) => (
<option key={customer.id} value={customer.id}>
{customer.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>
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
step="0.01"
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"
aria-describedby="amount-error"
/>
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Invoice Status */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
aria-describedby="status-error"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
>
Pending <ClockIcon className="h-4 w-4" />
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
aria-describedby="status-error"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
>
Paid <CheckIcon className="h-4 w-4" />
</label>
</div>
</div>
</div>
</fieldset>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
<div id="create-error" aria-live="polite" aria-atomic="true">
{state.message &&
<p className="mt-2 text-sm text-red-500">
{state.message}
</p>}
</div>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
Cancel
</Link>
<Button type="submit">Create Invoice</Button>
</div>
</form>
);
}
'use client';
import { CustomerField, InvoiceForm } from '@/app/lib/definitions';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { Button } from '@/app/ui/button';
import { updateInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const initialState: State = { message: null, errors: {} };
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
const [state, formAction] = useActionState(updateInvoiceWithId, initialState);
return (
<form action={formAction}>
<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 cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue={invoice.customer_id}
aria-describedby="customer-error"
>
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.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>
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
step="0.01"
defaultValue={invoice.amount}
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"
aria-describedby="amount-error"
/>
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Invoice Status */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
defaultChecked={invoice.status === 'pending'}
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
aria-describedby="status-error"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
>
Pending <ClockIcon className="h-4 w-4" />
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
defaultChecked={invoice.status === 'paid'}
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
aria-describedby="status-error"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
>
Paid <CheckIcon className="h-4 w-4" />
</label>
</div>
</div>
</div>
</fieldset>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
<div id="update-error" aria-live="polite" aria-atomic="true">
{state.message &&
<p className="mt-2 text-sm text-red-500">
{state.message}
</p>}
</div>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
Cancel
</Link>
<Button type="submit">Edit Invoice</Button>
</div>
</form>
);
}
'use server';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
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(),
});
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
const CreateInvoice = FormSchema.omit({ id: true, date: true });
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(prevState: State, formData: FormData) {
// 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.',
};
}
// データベースに挿入するデータを準備する
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// データベースにデータを挿入
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
return {
// データベースエラーが発生した場合は具体的なエラーメッセージを返す
message: 'Database Error: Failed to Create Invoice.',
};
}
// 請求書ページのキャッシュをバリデーションし、ユーザーをリダイレクトする
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
export async function updateInvoice(
id: string,
prevState: State,
formData: FormData
) {
// Zodを使用してフォームフィールドのバリデーションを行う
const validatedFields = UpdateInvoice.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.',
};
}
// データベースに挿入するデータを準備する
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
// データベースのデータを更新
try {
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
} catch (error) {
// データベースエラーが発生した場合は具体的なエラーメッセージを返す
return { message: 'Database Error: Failed to Update Invoice.' };
}
// 請求書ページのキャッシュをバリデーションし、ユーザーをリダイレクトする
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
export async function deleteInvoice(id: string) {
try {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
return { message: 'Deleted Invoice.' };
} catch (error) {
return { message: 'Database Error: Failed to Delete Invoice.' };
}
}
まとめ
next lint
コマンドで静的解析を行う。(今回はnpmのスクリプトで実行)- Zodの
parse()
でバリデーションを行うことができるが、safeParse()
を使用することでエラーを取得することができる。 aria-describedby
とid
でinput
要素とメッセージとの関係を表すことができる。今回はエラーメッセージとして利用。