フォーム値はロジックに寄せよ

Article

#感想戦

#設計

#React

#TypeScript

syakoo

みなさんは、React x TypeScript でフォームを実装するとき、どのように実装していますか?

フォームについてよくよく考えてみると、フォームのそれぞれのフィールドとロジックで持つ値はデータ形式 (いわゆる型) が異なります。

flowchart LR
  subgraph Form
    subgraph input["input"]
      string
    end
    subgraph multiselect["multiselect"]
      stringArray["string[]"]
    end
  end
  subgraph Logic
    subgraph name["name"]
      name_string["string"]
      name_undefined["undefined"]
    end
    subgraph categories["categories"]
      categories_stringArray["string[]"]
      categories_undefined["undefined"]
    end
  end
  Logic --> Form
  Form --> Logic

Logic では各フィールドの値を stringstring[] で、値がない場合は undefined として表現します。 それに対して、<input /> や UI ライブラリによくある <MultiSelect /> コンポーネントは stringstring[] で、値がない場合は空の ""[] として表現します。

この表現の違いをどちらに寄せていくかで、実装の自然さが変わってきます。

Not Good: ロジックを Form に寄せる

ロジックの方が自由度があるために、ロジックを Form に寄せることがよくあるかと思います。 しかしそうすると、型の表現に限界が来てしまい、不自然な実装になってしまったり必須と任意の区別がつけられなくなります。

例: 型の表現の限界

zod で吸収するように実装します
const formSchema = z.object({
  title: z.string(),
  comment: z.string(),
  tags: z.array(z.string()),
  categories: z.array(z.string()),
});
title と tags は必須項目にしてねー
はい

... string は文字数をチェック、string[] は配列数をチェックすれば良さそうですね

const formSchema = z.object({
  title: z.string().min(1), // 必須チェック
  comment: z.string(),
  tags: z.array(z.string()).min(1), // 必須チェック
  categories: z.array(z.string()),
});
フォームの型を取ってきましょうか
...
必須と任意がどれがどれだかわからない...
// type FormValues = z.infer<typeof formSchema>;
type FormValues = {
  title: string;
  comment: string;
  tags: string[];
  categories: string[];
};
あ、
追加で number のフィールドを任意項目でよろしくー
...

Good: Form をロジックに寄せる

Form をロジックに寄せ、Form と Logic 間の変換を挟むことによって、型の表現の限界を解消し、不自然な実装を避けることができます。 つまり、Logic における値なしの表現を Form の空文字列のままにするのではなく、undefined にする処理を挟みます。(Logic の undefined を Form の空文字列に変換する処理も必要)

例: 型の表現の限界を解消

zod の preprocess を使って、フォームの値をロジックに渡す前に変換します

const formSchema = z.object({
  title: z.string(),
  comment: z.string(),
  tags: z.array(z.string()),
  categories: z.array(z.string()),
});
 
const emptyToUndefined = (values) => {
  // 各フィールドの空文字や空配列を undefined に変換する
};
const formResolver = z.preprocess(emptyToUndefined, formSchema);
title と tags は必須項目にしてねー
はい

... どちらも z.string()z.array(z.string()) で必須チェックはできています

comment と categories を undefined を許容するようにします

const formSchema = z.object({
  title: z.string(),
  comment: z.string().optional(), // 任意チェック
  tags: z.array(z.string()),
  categories: z.array(z.string()).optional(), // 任意チェック
});
フォームの型を取ってきます...
// type FormValues = z.infer<typeof formSchema>;
type FormValues = {
  title: string;
  comment?: string | undefined;
  tags: string[];
  categories?: string[] | undefined;
};
必須項目と任意項目がはっきり分かりますね

まとめ

フォーム値をロジックに寄せることによって、型の表現の限界を解消し、不自然な実装を避けることができます。

...はじめてチャット形式のまとめ方をしてみましたが、思ったより冗長になりますね。使い所が難しいなぁと思いました。

参考

より詳細の説明として以下の記事が参考になりました。

Zenn
ようへいさんによる記事
zenn.dev
Zenn