Open10

valibotとconformでフォームのバリデーションを作る

じょうげんじょうげん
  • Conformの仕様について

フォームの状態管理ライブラリ
制御コンポーネントと呼ばれる、useState/useReducerのような再レンダリングを伴う状態管理ではなく、イベントハンドラを使用した購読に特化しているのが特徴。
そのため、React Hook Formのwatch()に対応するAPIは存在しない。
入力値へのアクセスはrefのように、イベントハンドラ内などで同期的にアクセスするか、サーバー側で検証することになる。

クライアント、サーバー横断でリアルタイムバリデーションを構築可能。
状態管理に頼らないことによって、パフォーマンスのメリットとJavaScriptが使用できない環境でもフォームを送信できるメリットがある

Next.jsやRemixなどのサーバーフレームワークとの親和性が注目されているが、onSubmit以外のAPIについてはクライアントサイドのみのSPAでも利用可能。

Conformの何が嬉しいのか

  • 使用側、実装側双方のコードがシンプルになる
  • UIライブラリとのインテグレーションもシンプル
  • Progressive Enhancement対応
  • 状態管理が減るため、パフォーマンスが良くなる
  • 動的に入力欄を増減させるような複雑なフォームも対応可能

惜しい点
SPA向けにonSubmitのサーバー検証をOmitできる機能が欲しい

非制御コンポーネントを使っているReact Hook Formと似ているが、RHFがrefを各コンポーネントにばら撒いて各refにイベントハンドラを仕込むのに対し、Conformは各コンポーネントにはname属性のみを登録するだけでそのnameを元に単一のイベントハンドラで全てのchangeEventを監視するアプローチを取っている。

非制御コンポーネントの課題として、データの取得前の型と取得後の型が変わってしまうという不都合がある。
ネイティブのinputは状態管理がいらない代わりにFormData APIがベースになっている都合上、numberや配列をvalueに与えたとしても出力の型がstringになってしまう。
Conformでは、そのデメリットをバリデーションライブラリとの密な連携で解消している。
公式のparseWithZodを使うと、スキーマをnumber()で定義するだけで暗黙的に型の変換を行ってくれる。

じょうげんじょうげん

バリデータライブラリとのintegrationを使うと、スキーマ定義に合わせて暗黙的に型変換を行ってくれる。
公式から提供されている、@conform-to/zod@conform-to/yupに加え、有志によるconform-to-valibotがある。

じょうげんじょうげん

conformでstateが使われている場所がgetInputPropsのfield.hoge.errorsとfield.hoge.valid。
getInputPropsを親コンポーネントで展開するのであれば問題にならないが、カスタムのTextFieldコンポーネントを作ってその中でgetInputPropsする場合はaria-invalidの変更に反応できない。
素直に親コンポーネントでaria-invalid={!field.meta.valid}するか、Conformが提供してくれているFormProviderとuseFieldを使おう。

じょうげんじょうげん

valibotの必須、任意のスキーマ管理方法

スキーマに対して、v.optional()を付けると型定義上?を付けたのと同じになる。
conformと組み合わせて使うときにgetValibotConstraint() を使用することで自動的にフォームの制約にrequiredが付くが、これを付けるとそれを回避することができる。

const schema = v.object({
  name: v.string(),
  age: v.optional(v.number()),
  address: v.string(),
});

type Obj = v.InferOutput<typeof schema>; // { name: string; age?: number | undefined; address: string; }

v.nonOptional()を付けると未入力の場合に、個別に異なるエラーメッセージを設定できる。

const schema = v.object({
  name: v.nonOptional(v.string(), "名前は必須です"),
  age: v.number(),
  address: v.nonOptional(v.string(), "住所は必須です"),
});

全ての要素に対してまとめて同一のエラーメッセージを設定したい場合はv.required()が使える。

const schema = v.required(
  v.object({
    name: v.string(),
    age: v.number(),
    address: v.string(),
  })
  "必須要素です"
)

v.required()の第二引数には、メッセージを設定したい要素を指定することができる

const schema = v.required(
  v.object({
    name: v.string(),
    age: v.number(),
    address: v.string(),
  })
  ,[ 'name', 'age'],
  "必須要素です"
)

v.required()v.optional()では外側のv.required()が優先されてしまうため、以下のような使い方はできない。
ネストされたオブジェクトに対しては効かないため、個別にv.required()を設定する必要がある。
TypeScriptのRequired<T>と同じ振る舞いをすると考えるとわかりやすい。

const schema = v.required(
  v.object({
    name: v.string(),
    age: v.optional(v.number()), // ❢❢これは必須に書き換えられてしまう
    address: v.string(),
  }),
  "必須要素です",
);

逆に、まとめて任意にしたい場合はv.partial()を使う
こちらはPartial<T>と同じような振る舞いをする。

const schema = v.partial(
  v.object({
    name: v.string(),
    age: v.number(),
    address: v.string(),
  })
)

type Obj = v.InferOutput<typeof schema>; // { name?: string | undefined; age?: number | undefined; address?: string | undefined; }

v.partial()の第二引数には、任意にしたい要素を指定することができる

const schema = v.partial(
  v.object({
    name: v.string(),
    age: v.number(),
    address: v.string(),
  })
  ,[ 'name', 'address']
)

type Obj = v.InferOutput<typeof schema>; // { name?: string | undefined; age: number; address?: string | undefined; }

v.partial()v.required()は組み合わせて使うこともできる

const schema = v.required(
  v.partial(
    v.object({
      name: v.string(),
      age: v.number(),
      address: v.string(),
    })
  )
  ,[ 'name', 'address'],
  "必須要素です"
)

type Obj = v.InferOutput<typeof schema>; // { name: string; age?: number | undefined; address: string; }
じょうげんじょうげん

conformテスト時にhappy-domを使っているとformのインスタンスが消滅する謎の不具合があるらしい
jsdomにしてみたがなぜかうまく動かないテストがあったのでvitestのbrowser modeを導入した

じょうげんじょうげん

動的なフォームもConformのユーティリティで簡単に作成することができる。
formのcontextに生えているform.insert(),form.remove()メソッドを使用することで追加と削除が行える。
このメソッドはクライアントサイドで実行される。
もしProgressive Enhancement対応を行いたい場合はform.insert.getButtonProps()をボタンコンポーネントに展開すればintentをボタンに設定し、Submit時にサーバー側で判定することができるようになる。
その場合はSubmitされてしまうため、クライアントサイドでの実行は行われなくなる。

/**
 * 動的に追加可能な入力フィールド
 * 以下のようなJSON構造に対応する
 * ```json
 * {
 *  "fields": ["value1", "value2", "value3"]
 * }
 * ```
 */
export const DynamicInputField: FC<FieldProps<string[]> & ComponentProps<typeof Input>> = ({
  name,
  helperMessage,
  label,
  ...inputProps
}) => {
  const id = useId();
  const [fieldMeta, form] = useField(name);

  const isRequired = fieldMeta.minLength ?? 0 > 0;

  const handleAddField = useCallback(() => {
    form.insert({ name });
  }, [form, name]);

  const handleRemoveField = useCallback(
    (index: number) => () => {
      form.remove({ name, index });
      if (fieldMeta.getFieldList().length === 0) {
        form.insert({ name });
      }
    },
    [fieldMeta, form, name],
  );

  return (
    <>
      <Grid templateColumns="auto 1fr" alignItems="start" gap={1}>
        <Label>{label}</Label>
        <Tag w="fit-content" size="sm" rounded="md" colorScheme={isRequired ? "red" : "blue"}>
          {isRequired ? "必須" : "任意"}
        </Tag>
      </Grid>
      <Grid templateColumns="1fr auto" gap={4}>
        {fieldMeta.getFieldList().map((field, index) => (
          <Fragment key={field.name}>
            <Input
              {...getInputProps(field, { type: "text" })}
              {...inputProps}
              aria-labelledby={`${id}`}
            />
            <Button type="button" variant="danger" onClick={handleRemoveField(index)}>
              <Trash2 />
              <Text>削除</Text>
            </Button>
            {fromObject(getFieldErrorProps(field))(
              ({ isInvalid, errorMessage }) =>
                isInvalid && <ErrorMessage>{errorMessage}</ErrorMessage>,
            )}
          </Fragment>
        ))}
      </Grid>
      <HStack justifyContent="space-between">
        <HelperMessage>{helperMessage}</HelperMessage>
        <Button onClick={handleAddField} w="min-content">
          <Plus />
          <Text>追加</Text>
        </Button>
      </HStack>
    </>
  );
};
じょうげんじょうげん

UIライブラリのSelectコンポーネントは、見た目をリッチにするために、ネイティブのselect要素とoptionを使わずに独自の実装をしている場合があり、その場合FormDataで取得できるvalueがリクエストに含まれなくなってしまう。
そのようなコンポーネントの値をFormDataが認識できるようにするために使うのがuseInputControl
これを使うことで、受け取ったmetadataに応じたhidden inputをform内に描画してくれるようになる。
changeEventやblurEventを監視することで、値をコントロールする。
stateが使われているので、値が変わるたびに再レンダリングが起きる。
このAPIは普通にクライアントサイドJavaScriptなので、これを使ったコンポーネントはProgressive Enhancementが失われる。
これを失われないようにするためにはuseLayoutEffectを使ってSSR時とCSR時で別々のコンポーネントをレンダリングする必要がありそう。

あと、自分がハマったのが下記。

fieldset要素にdisabledをセットすることでその配下の入力欄をFormDataに含めないというようなことが可能だが、useInputControlがレンダリングするhidden selectはform最下部に要素を描画してしまう。
fieldsetの外側に要素がレンダリングされてしまうため、画面上では見えないのにリクエストにデータが含まれてしまう。
対策として以下のようなカスタムフックを作って無理やり書き換えるようにした。

const useMoveHiddenElement = (name: string, dep: unknown) => {
  useEffect(() => {
    const targetElement = document.querySelector(`[aria-hidden="true"][name="${name}"]`);
    const referenceElement = document.querySelector(`[name="${name}"]:not([aria-hidden])`);

    if (targetElement && referenceElement) {
      referenceElement.insertAdjacentElement("afterend", targetElement);
    }
  }, [name, dep]);
};

これはぜひライブラリ側で対応して欲しいですね。
現在ドキュメントにも載っていないunstable_useControllunstable_Controll というrefを使った実装があるようなので、今後対応される気はしています。

じょうげんじょうげん

Conformのsubmission検証中にエラーがスローされると強制的にsubmitされてサーバー側検証がトリガーされる。
勝手にSubmitされてクエリパラメーターにintent=validateって出るやつがこれ。
schema定義は完璧でないと、意図せぬsubmitが発生して、リロードされてしまう。
特に気をつけたいのが、動的フォームのスキーマ。inputが0件の場合、FormDataから取り出すinputが無いため、検証結果としてはundefinedになる。
配列としてだけではなく、undefinedにもなりうるので、オプショナルにする必要がある。