Next.js App Router、Server Actions、Conform、Zodで条件分岐があるフォームを作る

はじめに

アソビューのフロントエンドテックリードの井上です

Next.jsのApp Routerがリリースされてから数ヶ月経ち、本番サービスに適用して運用しているような方々も出てきた今日このごろのフロント界隈かと思います。
弊社でもApp Routerの適用したアプリケーションを開発中です。

さて、これまでフォームのバリデーションライブラリとしてreact-hook-formを使うケースが多かったのですが、Next.jsのServer Actionsへの対応がまだ検証段階(2024/5/23現在)、ということもあり、対抗馬としてRemix や Next.js のようなサーバーフレームワークを完全にサポートするという謳い文句のconformを試してみようと思います。
通常の使い方の解説記事はすでに多くの方が書かれていますので、今回は少し複雑なケースに適用した場合について検証してみました。

作るもの

カスタム質問項目のフォームを作ります。イメージはGoogle Formです。
作成者は自由にフォーム作成画面でフリーテキスト、複数選択のセレクトボックス、単一選択のラジオボタンなどを追加し、必須かどうかを設定できます。
回答者はそれらの質問に回答を送信します。
こういったカスタム入力項目を設定するケースは、サービスサイトの開発や運営においてよく見られると思います。
弊社のサービスでも同様のフォームがあり、以前はreact-hook-formで実装していましたが、今回は同等の機能をConformで実装してみます。

要件としては

  • 質問項目リストはバックエンドなどから配列で受け取ってそれを元にFormを構築する
  • inputTypeという項目で入力項目がフリーテキストなのか複数選択なのか、単一選択なのかが分かれておりその種類と必須フラグによってバリデーションが変わる
  • サーバーとクライアントでバリデーションは共通。クライアントはonBlurでリアルタイムにバリデーションを実施する想定

参考:Google Form

コードと解説

ファイル構成

/app
  /actions
    sendAnswer.ts
  /components
    QuestionForm.tsx
  constants.ts
  schema.ts
  page.tsx

Zodのスキーマを作る

import { z } from 'zod';
import { Question_InputType } from './constants';

// 共通フィールドの定義
const baseQuestionSchema = z.object({
  inputType: z.nativeEnum(Question_InputType),
  questionId: z.number(),
  required: z.boolean(),
});

// 各質問タイプのスキーマ定義
const freeQuestionSchema = baseQuestionSchema.extend({
  inputType: z.literal(Question_InputType.INPUT_TYPE_FREE),
  singleValue: z.string({required_error: '入力は必須です'}).max(2000, '2000文字以内で入力してください'),
});

const multiChoiceQuestionSchema = baseQuestionSchema.extend({
  inputType: z.literal(Question_InputType.INPUT_TYPE_MULTI_CHOICE),
  multiValues: z.array(z.union([z.string(), z.literal(false)])),
});

const singleChoiceQuestionSchema = baseQuestionSchema.extend({
  inputType: z.literal(Question_InputType.INPUT_TYPE_SINGLE_CHOICE),
  singleValue: z.string(),
});


// メインの質問スキーマ
export const questionSchema = z.discriminatedUnion('inputType', [
  freeQuestionSchema,
  multiChoiceQuestionSchema,
  singleChoiceQuestionSchema,
]).superRefine((arg, ctx) => {
  if (!arg.required) return ctx;
  const isSingleChoiceQuestion = arg.inputType === Question_InputType.INPUT_TYPE_FREE || arg.inputType === Question_InputType.INPUT_TYPE_SINGLE_CHOICE;
  if (!isSingleChoiceQuestion && !arg.multiValues.some((value) => !!value)) {
    ctx.addIssue({
      path: ['multiValues'],
      code: z.ZodIssueCode.custom,
      message: '必ず一つ以上選択してください',
    });
  }
});

export const formSchema = z.object({
  questions: z.array(questionSchema).nonempty(),
});

export type QuestionType = z.infer<typeof questionSchema>;
export type FormSchemaType = z.infer<typeof formSchema>;

まずはZodのスキーマを作ります。
z.discriminatedUnionによってinputTypeで分岐します。

参考:discriminatedUnion

自由入力と単一選択はsingleValue、複数選択はmultiValluesというフィールドに入力、選択値を設定する想定でそれらの値をチェックします。
また、必須フラグ(required)はsuperRefineを使いバリデーションします。

※inputTypeは表示の分岐には使うもので入力項目及び送信項目として不要な場合、フォームのschema内で定義するのは微妙ではありますが、discriminatedUnionの条件に含めるために追加しています。後述のhiddenのinputの話にも繋がります。

Formを構築するデータの構造

const initialQuestions = [
  {
    inputType: 'INPUT_TYPE_FREE',
    required: true,
    questionId: 1,
    questionText: '自由回答の質問です',
    questionOptions: [],
  },
  {
    inputType: 'INPUT_TYPE_MULTI_CHOICE',
    required: true,
    questionId: 2,
    questionText: '複数選択の質問です',
    questionOptions: [
      {
        questionOptionId: '00001',
        optionText: 'オプション1',
      },
      {
        questionOptionId: '00002',
        optionText: 'オプション2',
      },
      {
        questionOptionId: '00003',
        optionText: 'オプション3',
      },
    ],
  },
  {
    inputType: 'INPUT_TYPE_SINGLE_CHOICE',
    required: true,
    questionId: 3,
    questionText: '単一選択の質問です',
    questionOptions: [
      {
        questionOptionId: '00001',
        optionText: '非常に良い',
      },
      {
        questionOptionId: '00002',
        optionText: 'まあまあ良い',
      },
      {
        questionOptionId: '00003',
        optionText: '普通',
      },
      {
        questionOptionId: '00004',
        optionText: 'まあまあ悪い',
      },
      {
        questionOptionId: '00005',
        optionText: '非常に悪い',
      },
    ],
  },
];

サーバーサイドから取得することを想定したFormを構築するためのデータです。

Server Actions

// actions/sendAnswer.ts
import { parseWithZod } from '@conform-to/zod';
import { formSchema } from '../schema';
import { redirect } from 'next/navigation';

export async function sendAnswer(prevState: unknown, formData: FormData) {
  const submission = parseWithZod(formData, {
    schema: formSchema,
  });

  if (submission.status !== 'success') {
    return submission.reply();
  }

  redirect(`/?value=${JSON.stringify(submission.value)}`);
}

Form

// components/QuestionForm.tsx
'use client';

import React, { useActionState } from 'react';
import { useForm, getFormProps, getFieldsetProps, getInputProps, getSelectProps, getTextareaProps } from '@conform-to/react';
import { Question_InputType } from '../constants';
import { formSchema, FormSchemaType } from '../schema';
import { sendAnswer } from '../actions/sendAnswer';
import styles from './QuestionForm.module.css';
import { parseWithZod } from '@conform-to/zod';
import { useFormState } from 'react-dom';

type QuestionOption = {
  questionOptionId: string;
  optionText: string;
}

type QuestionData = {
  inputType: string;
  required: boolean;
  questionId: number;
  questionText: string;
  questionOptions?: QuestionOption[];
}

type QuestionFormProps = {
  initialQuestions: QuestionData[];
}

const QuestionForm: React.FC<QuestionFormProps> = ({ initialQuestions }) => {
  const [lastResult, action] = useFormState(sendAnswer, undefined);
  
  const [form, fields] = useForm({
    defaultValue: {
      questions: initialQuestions.map(({ questionText, questionOptions, ...rest }) => rest),
    },
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: formSchema });
    },
    shouldValidate: 'onBlur',
  });

  const questions = fields.questions.getFieldList();
  return (
    <form action={action} {...getFormProps(form)}>
      {questions.map((question, index) => {
        const questionFields = question.getFieldset();
        const initialQuestion = initialQuestions[index];
        return (
          <fieldset key={question.key} {...getFieldsetProps(question)}>
            <input
                {...getInputProps(questionFields.inputType, { type: 'hidden' })}
                key={questionFields.inputType.key}
            />
            <input
                {...getInputProps(questionFields.questionId, { type: 'hidden' })}
                key={questionFields.questionId.key}
            />
            <input
                {...getInputProps(questionFields.required, { type: 'hidden' })}
                key={questionFields.required.key}
            />
            <div>
              <label>{initialQuestion.questionText}</label>
              {questionFieldSet.inputType.value === Question_InputType.INPUT_TYPE_FREE && (
                <textarea
                  className={!questionFieldSet.singleValue.valid ? 'error' : ''}
                  {...getTextareaProps(questionFieldSet.singleValue)}
                  key={questionFieldSet.singleValue.key}
                />
              )}
              {questionFieldSet.inputType.value === Question_InputType.INPUT_TYPE_MULTI_CHOICE && (
                <select multiple {...getSelectProps(questionFieldSet.multiValues)} key={questionFieldSet.multiValues.key}>
                  {initialQuestion.questionOptions?.map(option => (
                    <option key={option.questionOptionId} value={option.optionText}>
                      {option.optionText}
                    </option>
                  ))}
                </select>
              )}
              {questionFieldSet.inputType.value === Question_InputType.INPUT_TYPE_SINGLE_CHOICE && (
                <select {...getSelectProps(questionFieldSet.singleValue)} key={questionFieldSet.singleValue.key}>
                  {initialQuestion.questionOptions?.map(option => (
                    <option key={option.questionOptionId} value={option.optionText}>
                      {option.optionText}
                    </option>
                  ))}
                </select>
              )}
              <div className={styles.error}>{questionFields.singleValue.errors || questionFields.multiValues.errors}</div>
            </div>
          </fieldset>
        );
      })}
      <button type="submit">Submit</button>
    </form>
  );
};

export default QuestionForm;

getFieldListとgetFieldSet

Conformでは配列やオブジェクトの入れ子構造になったformに関しても名前を推測して型安全にformを構築することができます。

今回、questionsは配列項目なので const questions = fields.questions.getFieldList(); を使いました。

conform.guide

また、配列の中のリストアイテムはオブジェクトになってますので const questionFields = question.getFieldset(); でフィールドセットを取得しました。

conform.guide

useForm

  const [form, fields] = useForm({
    defaultValue: {
      questions: initialQuestions.map(({ questionText, questionOptions, ...rest }) => rest),
    },
    lastResult,
    // クライアントバリデーション
    onValidate({ formData }) {
      return parseWithZod(formData, { schema: formSchema });
    },
    shouldValidate: 'onBlur',
  });

サーバーサイドから取得したinitialQuestionsはdefaultValueで設定します。 今回クライアントサイドのリアルタイムバリデーションも実施したいのでonValidate及びshouldValidate: onBlurを設定しました。

Formの構築(のためのスキーマの分岐)にだけ必要だった値をどうするか

例えばinputTypeはバックエンドへのポストデータとしては不要のため、クライアントからは送信不要とします。
その場合、当然画面から入力する必要はありませんし、送信必要な情報をフォームの項目として管理する必要は無いはずです。
ところが、、
Server Actionsに対応したフォームを作る場合formタグの中にinput項目を配置すれば送信データに含めることができますが、バックエンドへの送信は不要であってもバリデーションスキーマに項目が存在するので、input項目として配置しないとバリデーションで項目が未入力という事によりクライアントバリデーションでエラーになってしまいます。
HTML標準のformのsubmitによせてるのでそういった作りになっているのかと。
そのため下記のようにhidden項目として設定しました。
その後バックエンドに送る項目に含めるかどうかはServer Actionsで制御すればよいと思います。

            <input
                {...getInputProps(questionFields.inputType, { type: 'hidden' })}
                key={questionFields.inputType.key}
            />
            <input
                {...getInputProps(questionFields.questionId, { type: 'hidden' })}
                key={questionFields.questionId.key}
            />
            <input
                {...getInputProps(questionFields.required, { type: 'hidden' })}
                key={questionFields.required.key}
            />

※ちなみにIDなど追加の値を単体で送るのであればServer Actionsの関数にbindで追加する方法もありますが複数必要であったり入れ子構造の場合はこの方法が使えません。

簡素ですがこのようなフォームになりました

今回作ったフォーム

良かったポイント

クライアントバリデーションとサーバーサイドバリデーションのロジックの共通化

同じバリデーションスキーマを使ってクライアントサイドのリアルタイムのバリデーションとサーバーサイドのバリデーションが容易に書けます。
これまでバックエンドがJavaなど他の言語だった場合は入力バリデーションを双方に書く必要があり、二重メンテなどの問題がありましたが、容易に改竄可能なフロントからのリクエストの裏にNext.jsのサーバーサイドバリデーションが入ることで、その先にあるJavaなどのバックエンドアプリケーションに関しては同等のバリデーションはかけずにドメインレベルなどより低レイヤーのバリデーションに専念する設計にすることができ、セキュリティとメンテナンシビリティを両立できると思います。

ハマったポイント、注意点

getFieldList、getFieldsetから取得できるFieldMeta情報の使い方

const [form, fields] = useForm();

useFormから取得するFieldMeta情報を元に入れ子(配列やオブジェクト)になったFieldMeta情報を取得することができます。
それらはform要素を紐づける際に活用しますが

<input
   type="email"
   key={fields.email.key}
   name={fields.email.name}
   defaultValue={fields.email.initialValue}
 />

今回のdiscriminatedUnionを用いたスキーマの場合、分岐条件にリテラル型を使っていてもその情報は失われてしまいます。
例えば下記の場合の questionFieldSet.inputType.value はリテラル型ではなく string | undefined になってしまうため、この条件の中でquestionFieldSetの中のフィールドを型安全に扱えるというわけではありません。(思ってたんと違った。。)

 {questionFieldSet.inputType.value === Question_InputType.INPUT_TYPE_FREE && (
    <textarea
       className={!questionFieldSet.singleValue.valid ? 'error' : ''}
       {...getTextareaProps(questionFieldSet.singleValue)}
        key={questionFieldSet.singleValue.key}
     />
  )}

FieldMetaのvalueについてはこの辺の型定義ですが、Schemaが string, number, boolean, Date, bigint, null, undefined のいずれかであれば、FormValue<Schema>string | undefinedになっており。この条件により、InputTypeのようなリテラル型もstring | undefinedに変換されてしまっています。
あくまでformと紐づけるためのMetaDataであり、それ以上の機能は持っていないというこですね。

Zodの定義とバリデーションとサーバーサイドに送信する値

前述のhidden項目の値ですが、固有IDなど登録時にバックエンド側が必要とするものなら良いとして、今回の複雑なバリデーション条件のために使ったinputTypeなどが送信に不要だとしてもバリデーション条件として必要であれば便宜上hidden項目として設置しないとクライアント、サーバーサイドのバリデーションが通りません。
これまでreact-hook-formなどを用いたクライアントサイドバリデーションであればdefaultValueで設定するなどで柔軟に対処できましたが今回の設計ではそうではありませんでした。
ただ、これはHTML準拠のformに近く、何の値を使ってバリデーションしているのかが明示されるので見通しが良いとも言えるとは思います。(react-hook-formだと良く何が引っかかってるのかわからずブラックボックスになりがちで、formStateの中身を調べるなどすることが経験上多いです。)

まとめ

これまでReactフロントエンド開発の中で様々なformライブラリと戦って来ましたが(redux-form、formik、react-hook-form、なんかUIフレームワークに付属してるform ...etc)
App Router、ServerActionとConform、Zodを組み合わせることでより見通しが良いフォームの設計ができる兆しを感じました。
今回あえて複雑なフォームを試しに作って見た結果、課題も見えては来ましたが方向性としてはHTMLの標準に準拠した設計をベースに作っていけるとWeb開発者的には幸せになりそうです。

さいごに

アソビューでは「生きるに、遊びを。」を実現していくために、今後もプロダクトをさらに成長させていく必要があり、その仲間をまだまだ募集しております。少しでも興味を持っていただけた方は、ぜひエントリーを検討してみてください!

www.asoview.com

speakerdeck.com