【14】Next.js app routerのチュートリアルやってみる(useFormStateでエラーメッセージを表示)

# はじめに

Next.js app routerのチュートリアルの第14章のアウトプットをします。

https://nextjs.org/learn/dashboard-app/improving-accessibility

<details><summary>前の記事</summary>

【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)

https://qiita.com/naoyuki2/items/41585b11448036aec74a

</details>

# 第14章 アクセシビリティの向上

前回は、エラーが起きた際、ユーザーにフォールバックUIを表示させる方法について勉強しました。

そして今回は、サーバーアクションを用いて、フォームの検証をしていきます。

- useFormStateの使い方
- サーバー側でフォームを検証する方法

# サーバー側でフォームを検証

現在は、フォームの入力要素が空の状態で送信してしまうとエラーが発生してしまいます。

これを検証するために`create-form.tsx`で`useFormState`というフックを使用します。

## useFormStateとは

>useFormState は、フォームアクションの結果に基づいて state を更新するためのフックです。

https://ja.react.dev/reference/react-dom/hooks/useFormState

### 準備

`react-dom`から`useFormState`を`import`します。

クライアントコンポーネントでしか動作しないため、`use client`をつける。

```diff_tsx:/app/ui/invoices/create-form.tsx
+'use client';
 
// ...
+import { useFormState } from 'react-dom';
```

`useFormState`は２つの引数を受け取ります。

- 第１引数には、フォーム送信時に発火させる関数を入れる
    - 今回はフォーム送信時に`createInvoice`を発火させる
- 第２引数には`state`の初期値を入れる
    - 今回は`message`と`errors`を持った`initialState`というオブジェクトを入れる

`useFormState`は２つの戻り値があります。

- １つめの戻り値は、要素の現在の状態を受け取る
    - 初回レンダリング時は`initialState`が入っている
- ２つめの戻り値は、フォームアクションをうけとる
    - これを`<form>`タグの`action`属性にいれる

```diff_tsx:/app/ui/invoices/create-form.tsx
'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`というライブラリを使ってフォームデータを検証しました。

その中にエラーメッセージの記述を追加しておきましょう。

```diff_tsx:/app/lib/action.ts
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`受け取るように変更し、その型を定義しましょう。

```diff_tsx:/app/lib/actions.ts
+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()`に書き換えましょう。

```diff_tsx:/app/lib/actions.ts
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`の方がエラーハンドリングが楽なので使用するみたいです。

```diff:バリデーション成功時にsafeParse()が返すオブジェクト
{
  success: true,
  data: {
    customerId: '3958dc9e-712f-4377-85e9-fec4b6a6442a',
    amount: 235,
    status: 'pending'
  }
}
````
```diff:バリデーション失敗時にsafeParse()が返すオブジェクト
{ success: false, error: [Getter] }
````

---

続いて、バリデーション失敗時にエラーメッセージを格納していきます。

```diff_tsx:/app/lib/actions.ts
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`オブジェクトは以下のような形式のオブジェクトに変換されます。

```:flattenメソッドで変換したZodErrorオブジェクト
{
  formErrors: [],
  fieldErrors: {
      customerId: [ '顧客を選択してください。' ],
      amount: [ '0より大きい金額を入力してください。' ],
      status: [ 'pending または paid を選択してください。' ]
  }
}
```
<details><summary>formErrorsについて補足</summary>

`formErrors`オブジェクトは、フォーム全体に関連するエラーメッセージを格納します。

これは、特定のフィールドではなく、フォーム全体に適用されるバリデーションルールがある場合に使用されます。

例えば、複数のフィールドを組み合わせたバリデーション（パスワードと確認用パスワードが一致することを確認するなど）がある場合などです。

</details>

これによって`validatedFields.error.flatten().fieldErrors`という記述で、`state`にエラーメッセージを格納できるというわけです。

言い忘れてましたが、フォームアクションで実行された関数の`return`の値によって`state`が更新されます。

これでサーバー側は完了です。

<details><summary>actions.tsの最終的なコード</summary>

```diff_tsx:/app/lib/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');
}
```

</details>

### エラーメッセージを表示しよう

サーバーアクションが返した`return`によって`state`が更新されるようになりました。

次はそれを表示しましょう。

```diff_tsx:/app/ui/invoices/create-form.tsx
<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>`に記述することでスクリーンリーダーが適切に読み上げてくれます。

# おわりに

だんだんサーバーアクションが分かってきた気がする。

# 参考

https://azukiazusa.dev/blog/use-form-state-to-display-error-messages-in-server-actions-forms/

# 次の記事

https://qiita.com/naoyuki2/items/f5ceeb2870a301affe8e