🚸

Learn Next.js のフォーム UX 改善・修正

2025/01/22に公開

Next.js のチュートリアル Learn Next.js を進めていて、フォームの動作が微妙な所が有ったので、実際の実装も想定した調整の忘備録になります。
(何れも専門と言う訳では有りませんで、もし他にベターな解決策など有りましたらご教示くださいませ🤓)

実際に課題に感じたポイントを数点ピックします。

  1. 送信エラー時にフォームの値がリセットされる
  2. Zod の enum バリデーションのエラーテキストが期待通りではない
  3. 入力欄でのエンターキー操作に対応

何れも14章の Improving Accessibility の調整が対象になります。

1. 送信エラー時にフォームの値がリセットされる

幾らかググったり AI に聞いてみたりすると、useActionState のアクションでフォームの値を返す必要が有る様でした。

実際に試してみて適用できたので、実装例を示します。
手順としては以下の様な感じ。

a. State 型に formData を追加
b. action の戻り値に formData を追加
c. form に値を適用

/app/lib/actions.ts
 export type State = {
   errors?: {
     ...
   };
   message?: string | null;
+  /** エラー時の入力値保持 */
+  formData?: FormData;
 };

export async function createInvoice(prevState: State, formData: FormData) {
  ...
  if (!validatedFields.success) {
    return { errors: ..., message: ..., formData };
  }
  ...
}

formData は引数に存在するので return のオブジェクトに追加するだけで OK でした。
FormData は Payload (object) で返した方が楽に思いましたが、Perplexity などで確認すると、画像などをアップロードする場合の File の処理が FormData のままの方が簡単だと回答を受けたので、一貫性を取る目的で FormData のまま処理する様にしています。

あとはコンポーネント側の入力欄などに defaultValue で値を渡せば OK です。
少しハマった所が有ったので、諸々を解消したコード例を載せておきます。

/app/ui/invoices/create-form.tsx
const formData = new FormData();
// NOTE: customerId (select 要素) は指定が無いと disabled の先頭要素が選択されない
formData.append("customerId", "");
const initialState: InvoiceState = { formData };

export default function Form({ customers }: { customers: CustomerField[] }) {
  const [state, formAction, isPending] = useActionState(createInvoice, initialState);

  return (
    <form action={formAction}>
      ...
      <select
        id="customer"
        name="customerId"
        ...
        defaultValue={`${state?.formData?.get("customerId")}`}
        ...
      >
      ...
      <input
        id="amount"
        ...
        defaultValue={`${state?.formData?.get("amount")}`}
        ...
      />
      ...
      <Button type="submit" disabled={isPending}>Create Invoice</Button>
      ...

ついでに isPending でフォーム送信 submit の連打防止も入れています。

実装後に英語で改めて検索した所、以下の記事にも同様の実装が紹介されていました。

How to (not) reset a form after a Server Action in React

formData にパスワードなど表に出さない方が良さそうな秘匿情報を含む場合、返す値をフィルターで限定、ないし弾いておいた方が良いかもしれません。

2. Zod の enum バリデーションのエラーテキストが意図通りではない

こちらの GitHub issue に答えになりそうな記述が有りました。

nativeEnum invalid_type_error not returning error on wrong enum
—— https://github.com/colinhacks/zod/issues/2919

書き途中でしたが、コードを整理してた所どうも勘違いだった様です。

以下の invalid_type_error のエラー文が適用されない事象にハマってたんですが、何れも message に切り替えたら問題なくなったと言うものでした。
invalid_type_error は詳細な設定な様ですが、どちらでも動作に変化は無いように見えたので、動作検証して問題なければ全て message に変えてしまって良いのではと思います。

/app/lib/actions.ts
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(),
});

3. 入力欄でのエンターキー操作に対応

form って入力欄 (<input type="text"> 要素など) でフォーカス中にエンターキーを押しても送信できてしまう物で、それで送信した際に何故か form action={formAction} のままだとフォームの値を復帰できなかったりする事が有ったので、action から onSubmit に切り替えました。

/app/ui/invoices/create-form.tsx
import { startTransition, useActionState, useRef } from "react";

export default function Form({ customers }: { customers: CustomerField[] }) {
  ...
  const formRef = useRef(null);
  const handleSubmit = (event: React.FormEvent) => {
    event.preventDefault();
    const form = formRef.current;
    // NOTE: 直接 formAction を実行するとエラーが出ます
    form && startTransition(() => formAction(new FormData(form)));
  };

  return (
    <form onSubmit={handleSubmit} ref={formRef}>
      ...

送信をボタンのクリック時だけに限定するなどの実装も可能かと思いますが、入力欄が1つしかないケースでは操作が楽だし、input 要素を required にしていればエンターキーを押すだけでフォーカスが移動するので、それはそれで追加の実装をせずに楽に使えるフォームになるので、個人的に有りかなと思っています。

onSubmit にするのもちょっと微妙な印象は有るので、もしもっと良い方法などご存知でしたらコメントなどで教えてくださいませ🫡

Discussion