🐷

react-hook-formで数値のバリデーションを考える

2023/06/18に公開

始めに

Reactでフォームのバリデーションをする場合、よくreact-hook-formを使うと思いますが、数値を入力する場合どうするのが良いか悩みました。入力中のテキストボックスはstringなので、最終的にはnumberで取得したいですがどうしてもちぐはぐしてしまいます。まだ結論は出ていませんが、いくつか方法論は出尽くしたので、備忘録として記事にしました。
なお、今回はMUIとyupを使って検証しましたので掲載するコードもそれらを使ったものになります。

数値のバリデーション案

まず前提として、InputNumberのような、数値入力専用のコンポーネントを用意するか否かで方針が変わります。通常のテキスト入力コンポーネントでも方法はありましたので、それぞれのパターンで検討しました。

通常のテキスト入力コンポーネントからバリデーションするパターン

まずは数値入力専用のコンポーネントを使わず、テキスト入力コンポーネントをそのまま使う場合の方法を考えます。

シンプルに入力中はstringで運用し、利用時にnumberに変換する

最初の方法はシンプルに入力中はstringで運用しようという考えです。正規表現を使えば数字かどうかはわかるので、数字のバリデーションだけしておき、最後利用するタイミングでnumberに変換するという考えです。

stringで運用する案
const schema = yup.object({
  numStr: yup.string()
    .required()
    .matches(/^[0-9]*$/, { message: '数字を入力してください' })
});

const App: FC = () => {
  const { control, handleSubmit } = useForm({
    resolver: yupResolver(schema)
  });
  
  const onSubmit = handleSubmit((data) => {
    // 必要なタイミングでキャストしておく
    const num = Number(data.numStr);
  });
  
  return (
    <form onSubmit={onSubmit}>
      <Controller
        name="numStr"
	control={control}
	render={({ field, fieldState }) => {
	  const errorMessage = fieldState.error
	    ? fieldState.error.message
	    : undefined;
	  return (
	    <TextField
	      inputRef={field.ref}
	      label="テキストで入力する"
	      value={field.value}
	      error={!!errorMessage}
	      helperText={errorMessage}
	      onBlur={field.onBlur}
	      onChange={field.onChange}
	    />
	  );
	}}
      />
    </form>
  );
}

懸念点

  • 折角バリデーションしているのに使用時にnumber型に変換する必要がある

バリデーション時にnumberに変換する

yupはバリデーション時でもデフォルトで変換処理を通してくれるため、スキーマはnumberとして定義して、入力データをバリデーションする際についでにnumberに変換してもらう案です。空文字の時は入力無しという意味だと思うので、nullに変換し、それ以外はnumberスキーマ標準の変換をします。

バリデーション時にnumberに変換する
+const schema = yup.object({
+  num: yup
+    .number()
+    .required()
+    .nullable()
+    .transform((value, originalValue) => {
+      // 空文字の場合はnullにする
+      return originalValue === '' ? null : value;
+    })
+    // 数値に変換できなかった場合のエラーメッセージ
+    .typeError('数字を入力してください')
+});

 const App: FC = () => {
   const { control, handleSubmit } = useForm({
+    resolver: yupResolver(schema, {
+      // デフォルトがfalseなので明示する必要はないが、この時transformもしてくれる
+      strict: false
+    })
   });
   
   const onSubmit = handleSubmit((data) => {
+    // 既にnumberにキャスト済みのdataが使える
   });

   return (
     <form onSubmit={onSubmit}>
       <Controller
         name="num"
         control={control}
         render={({ field, fieldState }) => {
           const errorMessage = fieldState.error
             ? fieldState.error.message
             : undefined;
+          // field.valueの型はnumberと表示されるが、入力していくとstringが入ってくる
+          const text = field.value == null ? '' : String(field.value)
           return (
             <TextField
               inputRef={field.ref}
               label="バリデーション時にnumberに変換する"
               value={text}
               error={!!errorMessage}
               helperText={errorMessage}
               onBlur={field.onBlur}
               onChange={field.onChange}
             />
           );
         }}
       />
     </form>
   );
 }

懸念点

  • 入力中はstringなので、field.valueの型がTypeScriptの型とずれている時がある

補足

コードをシンプルにするため.typeError('数字を入力してください')と全てのtypeエラーを同じエラー文言にしていますが、細かいハンドリングをしたい場合はこちらなどをご参照ください。
https://zenn.dev/longbridge/articles/39dbdf52baf66c

数値入力専用のコンポーネントを用意してバリデーションするパターン

続いてはInputNumberのようなコンポーネントを用意して、入力中もnumberで扱う方法について考えます。numberに変換できない場合はローカルステートで保持しておき、変換できたものだけ受け渡しをするようなイメージです。これだとインターフェースが綺麗になりそうですが、中途半端な状態をyupでバリデーションすることができず、正しくバリデーションできない問題が起きます。 なので、この中途半端な状態をどう親コンポーネントに伝えるかが鍵になります。

InputNumberコンポーネントの大枠
export type InputNumberProps = {
  inputRef: TextFieldProps["inputRef"];
  label?: string;
  value: number | null;
  errorMessage: string;
  onChangeValue: (newValue: number | null) => void;
};

const formatNumber = (num: number | null) => {
  if (num == null) {
    return "";
  }
  return num.toString();
};

const parseNumber = (numStr: string) => {
  if (numStr === "") {
    return null;
  }
  return parseInt(numStr, 10);
};

export const InputNumber: FC<InputNumberProps> = ({
  inputRef,
  label,
  value,
  errorMessage,
  onChangeValue
}) => {
  const [localText, setLocalText] = useState(formatNumber(value));

  // 親からvalueを変えられた場合の追従
  useEffect(() => {
    setLocalText(formatNumber(value));
  }, [value]);

  return (
    <TextField
      inputRef={inputRef}
      label={label}
      value={localText}
      error={!!errorMessage}
      helperText={errorMessage}
      onChange={(event) => {
        const text = event.target.value;
        setLocalText(text);

        const newValue = parseNumber(text);
	// 正しい数値に変換できた時だけ送る
        if (!Number.isNaN(newValue)) {
	  onChangeValue(newValue);
	}
	// 正しくないデータが入力されているとyup側にどう伝える?
      }}
    />
  );
};

NaNも送ってしまう

NaNは異常値ではありますが実はnumber型なので、これを送っても実は型エラーになりません(parseIntの返り値もNaNを返すケースがあってもnumber型ですし)。なのでこの値も返してしまって、異常値かどうかの判断はyup側で判断してもらうのが一つ目の方法です。

NaNも返すInputNumberコンポーネント
 // 変更の無いコードは一部省略

 const formatNumber = (num: number | null) => {
-  if (num == null) {
+  if (num == null || Number.isNaN(num)) {
     return "";
   }
   return num.toString();
 };

 export const InputNumber: FC<InputNumberProps> = ({
   inputRef,
   label,
   value,
   errorMessage,
   onChangeValue
 }) => {
   const [localText, setLocalText] = useState(formatNumber(value));

   // 親からvalueを変えられた場合の追従
   useEffect(() => {
+    // NaNの場合はスキップ
+    if (Number.isNaN(value)) {
+      return
+    }
     setLocalText(formatNumber(value));
   }, [value]);
 
   return (
     <TextField
       // 変更のないpropsは省略
       onChange={(event) => {
         const text = event.target.value;
         setLocalText(text);

         const newValue = parseNumber(text);
-        // 正しい数値に変換できた時だけ送る
-        if (!Number.isNaN(newValue)) {
-         onChangeValue(newValue);
-        }
+        // 変換に失敗してNaNになっていても送る
+        onChangeValue(newValue);
       }}
     />
   );
 };

このコンポーネントを使ってバリデーションを書くと以下のようになります。yupResolverのオプションでstrict: trueにしておくと勝手にnumberに変換しようとしなくなるため、思わぬ挙動を防ぐことができます(nullableを無くした状態だとnull値をnumberに変換しようとしてtypeErrorになってしまうなどを防げるなど)

NaNを返すInputNumberコンポーネントを使ったバリデーション
const schema = yup.object({
  numWithNaN: yup
    .number()
    .required()
    .nullable()
    .typeError('数字を入力してください')
});

const App: FC = () => {
  const { control, handleSubmit } = useForm({
    resolver: yupResolver(schema, {
      // trueにするとnumberに変換する処理が勝手に走らずに済む
      strict: true
    })
  })
  
  const onSubmit = handleSubmit((data) => {
    console.log(data);
  });
  
  return (
    <form onSubmit={onSubmit}>
      <Controller
        name="numWithNaN"
        control={control}
        render={({ field, fieldState }) => {
          return (
            <InputNumber
              inputRef={field.ref}
              label="NaNも含めたInputNumberコンポーネント"
	      value={field.value}
              errorMessage={fieldState.error?.message}
              onChangeValue={(value) => {
                field.onChange(value);
              }}
	    />
	  );
	}}
      />
    </form>
  )
}

懸念点

  • ローカルにデータを持っているため、エラー状態でコンポーネントを作り直す場合は問題が発生する
    • 例えば、ラジオボタンなどで入力フォームを切り替えるケース
    • この場合、NaNを返しているため変更前の数値も復元できない

Errorイベントを別途用意する

次はErrorイベントを別途用意する方法です。react-hook-formではsetErrorというメソッドが提供されており直接エラーをセットすることが可能なのでyupを使わず直接エラーをセットする方法になります。

Errorイベントも返すInputNumberコンポーネント
 // 変更の無いコードは一部省略
 
 export type InputNumberWithErrorProps = {
   inputRef: TextFieldProps["inputRef"];
   label?: string;
   value: number | null;
   errorMessage: string;
   onChangeValue: (newValue: number | null) => void;
+  onError: (errorMessage: string) => void;
 };

 export const InputNumber: FC<InputNumberProps> = ({
   inputRef,
   label,
   value,
   errorMessage,
   onChangeValue,
+  onError
 }) => {
   // 変更のないコードは一部省略
 
   return (
     <TextField
       // 変更のないpropsは省略
       onChange={(event) => {
         const text = event.target.value;
         setLocalText(text);

         const newValue = parseNumber(text);
         // 正しい数値に変換できた時だけ送る
         if (!Number.isNaN(newValue)) {
           onChangeValue(newValue);
+        } else {
+	   onError('数字を入力してください');
+	 }
       }}
     />
   );
 };
Errorイベントも返すInputNumberコンポーネントを使ったバリデーション
 const schema = yup.object({
+  numWithError: yup
+    .number()
+    .required()
+    .nullable()
 });

 const App: FC = () => {
-  const { control, handleSubmit } = useForm({
+  const { control, setError, clearErrors, handleSubmit } = useForm({
     resolver: yupResolver(schema, {
       // trueにするとnumberに変換する処理が勝手に走らずに済む
       strict: true
     })
   })
  
   const onSubmit = handleSubmit((data) => {
     console.log(data);
   });
  
   return (
     <form onSubmit={onSubmit}>
       <Controller
         name="numWithError"
         control={control}
         render={({ field, fieldState }) => {
           return (
             <InputNumber
               inputRef={field.ref}
               label="Errorを別途セットできるInputNumberコンポーネントを使う"
               value={field.value}
               errorMessage={fieldState.error?.message}
               onChangeValue={(value) => {
+                // 手動でセットしたエラーをクリアしておく
+                clearErrors('numWithError');
                 field.onChange(value);
               }}
+              onError={(errorMessage) => {
+                // 自前でエラーをセットするとエラー表示の条件が他と合わなくなりやすい
+                setError('numWithError', {
+                  message: errorMessage
+                });
+              }}
             />
           );
         }}
       />
     </form>
   )
 }

懸念点

  • エラーを自前でセットしているため、yupバリデーションのタイミングと合わせづらい(onBlur時にバリデーションしたいとかなど)
  • エラー状態でコンポーネントを作り直すと入力途中の情報が消える問題は引き続きある
    • ただNaNをvalueに登録していないため、確定済みの数値データには復元できる

エラーオブジェクトをvalueに含める

先ほどまではテキストデータをコンポーネント内部で持つ方法を取りましたが、この場合コンポーネントが作り直されるとデータが破棄される問題がありました。なのでテキスト情報もvalueに返してローカルにはデータを持たない方法を考えます。具体的にはTransformErrorのようなカスタムエラーを用意し、value: number | TransformErrorのようなインターフェースで実装します。Errorオブジェクトに入力中のテキストデータも含めることで、ローカル変数を持たずに入力中のデータを親が持つことができます。

Errorオブジェクトも返すInputNumberコンポーネント
+export class TransformError extends Error {
+  /** 変換対象の元データ */
+  public originalValue: string;
+
+  constructor(message: string, originalValue: string) {
+    super(message);
+    this.originalValue = originalValue;
+  }
+}

 // 変更の無いコードは一部省略
 
 export type InputNumberWithErrorProps = {
   inputRef: TextFieldProps["inputRef"];
   label?: string;
-  value: number | null;
+  value: number | null | TransformError;
   errorMessage: string;
-  onChangeValue: (newValue: number | null) => void;
+  onChangeValue: (newValue: number | null | TransformError) => void;
 };

-const formatNumber = (num: number | null) => {
+const formatNumber = (num: number | null | TransformError) => {
+  if (num instanceof TransformError) {
+    return num.originalValue;
+  }

   if (num == null || Number.isNaN(num)) {
     return "";
   }
   return num.toString();
 };
 
 const parseNumber = (numStr: string) => {
   if (numStr === "") {
     return null;
   }
+  if (!/^[0-9]+$/.test(numStr)) {
+    return new TransformError("数値変換できません。", numStr);
+  }
   return parseInt(numStr, 10);
 };

 export const InputNumber: FC<InputNumberProps> = ({
   inputRef,
   label,
   value,
   errorMessage,
   onChangeValue
 }) => {
-  const [localText, setLocalText] = useState(formatNumber(value));

-  // 親からvalueを変えられた場合の追従
-  useEffect(() => {
-    setLocalText(formatNumber(value));
-  }, [value]);
 
   return (
     <TextField
       // 変更のないpropsは省略
-      value={localText}
+      value={formatNumber(vallue)}
       onChange={(event) => {
         const text = event.target.value;
         setLocalText(text);

         const newValue = parseNumber(text);
-        // 正しい数値に変換できた時だけ送る
-        if (!Number.isNaN(newValue)) {
-         onChangeValue(newValue);
-        }
+        // 変換に失敗してTransformErrorになっていても送る
+        onChangeValue(newValue);
       }}
     />
   );
 };
Errorオブジェクトも返すInputNumberコンポーネントを使ったバリデーション
// NaNを返すパターンと書き方が一緒なので省略

懸念点

  • throwではなくonChangeでErrorオブジェクトを返しているのが違和感
  • あくまで入力中だが、valueのtypeがnumber | TransformErrorとなり型が微妙に異なっている
    • ただfield.onChangeが意外にもanyで受け付けているため、特にtypeエラーが起きるわけではない

まとめ

それぞれの手法の良し悪しを簡単にまとめると以下のようになります。個人的にはテキスト入力でnumberスキーマでキャストする方法か数値入力コンポーネントでエラーを含めて返す方法が良さそうかなと思いました。

方法 書きやすさ 型の整合性 submit後の
データの扱いやすさ
状態を復元できるか
テキスト入力で
そのままstring運用
×
テキスト入力で
numberスキーマでキャスト
数値入力コンポーネントで
NaNも含めて返す
×
中途半端な入力時は全て消える
数値入力コンポーネントで
エラーは別イベントでセットする
×
中途半端な入力時は変更前の数値になる
数値入力コンポーネントで
エラーも含めて返す

終わりに

以上が数字入力のバリデーションをするいくつかの方法でしたが、残念ながらまだ確実にこれ!って思うほどしっくりきたものが見つかりませんでした。もし他に良さそうな方法があればコメントしていただけると嬉しいです。
最後に検証用で使ったサンプルコードを載せますので、興味があるかたはご参照ください。

余談

今回react-hook-formを色々触って思ったのですが、入力中は中途半端な状態もあり得るため、最終出力時の型と入力中の型が合わずtypeエラーになってしまうことが気になりました。今回この辺の言及を避けるためrequiredを避けましたが、yup.nullable().required()とすると、nullの型が完全に排除されてしまい初期値として入れるのもtypeエラー扱いになっていました。何となく初期値を含めた入力中の型と最終結果の型は分かれていた方がもうちょっとしっくりとくる書き方ができるのかなぁと思いました。

Discussion