LoginSignup
2
1

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

Last updated at Posted at 2024-02-13

はじめに

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)

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

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

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

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

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

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

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

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

useFormStateとは

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

準備

react-domからuseFormStateimportします。

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

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

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

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

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

  • 1つめの戻り値は、要素の現在の状態を受け取る
    • 初回レンダリング時はinitialStateが入っている
  • 2つめの戻り値は、フォームアクションをうけとる
    • これを<form>タグのaction属性にいれる
/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)が発火します。

その際に現在のstateformDateを渡すようになりました。


サーバーアクション側の実装

前回の記事でZodというライブラリを使ってフォームデータを検証しました。

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

/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メソッドで型を検証する際に

customerIdstringを期待しているのに、number型のものが入っていたら、エラーを投げるようになりました。


続いてフォームアクションからstate受け取るように変更し、その型を定義しましょう。

/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()に書き換えましょう。

/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の方がエラーハンドリングが楽なので使用するみたいです。

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

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

/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とすることで、successfalseの場合だけエラーメッセージを格納するようにします。

zodライブラリのZodErrorオブジェクトには、flattenというメソッドがあります。このメソッドは、エラーオブジェクトをより扱いやすい形式に変換してくれます。

flattenメソッドを使用すると、ZodErrorオブジェクトは以下のような形式のオブジェクトに変換されます。

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

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

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

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

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

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

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

actions.tsの最終的なコード
/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');
}

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

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

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

/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>に記述することでスクリーンリーダーが適切に読み上げてくれます。

おわりに

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

参考

次の記事

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1