react-hook-formで数値のバリデーションを考える
始めに
Reactでフォームのバリデーションをする場合、よくreact-hook-formを使うと思いますが、数値を入力する場合どうするのが良いか悩みました。入力中のテキストボックスはstringなので、最終的にはnumberで取得したいですがどうしてもちぐはぐしてしまいます。まだ結論は出ていませんが、いくつか方法論は出尽くしたので、備忘録として記事にしました。
なお、今回はMUIとyupを使って検証しましたので掲載するコードもそれらを使ったものになります。
数値のバリデーション案
まず前提として、InputNumber
のような、数値入力専用のコンポーネントを用意するか否かで方針が変わります。通常のテキスト入力コンポーネントでも方法はありましたので、それぞれのパターンで検討しました。
通常のテキスト入力コンポーネントからバリデーションするパターン
まずは数値入力専用のコンポーネントを使わず、テキスト入力コンポーネントをそのまま使う場合の方法を考えます。
シンプルに入力中はstringで運用し、利用時にnumberに変換する
最初の方法はシンプルに入力中はstringで運用しようという考えです。正規表現を使えば数字かどうかはわかるので、数字のバリデーションだけしておき、最後利用するタイミングでnumberに変換するという考えです。
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スキーマ標準の変換をします。
+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エラーを同じエラー文言にしていますが、細かいハンドリングをしたい場合はこちらなどをご参照ください。
数値入力専用のコンポーネントを用意してバリデーションするパターン
続いてはInputNumber
のようなコンポーネントを用意して、入力中もnumberで扱う方法について考えます。numberに変換できない場合はローカルステートで保持しておき、変換できたものだけ受け渡しをするようなイメージです。これだとインターフェースが綺麗になりそうですが、中途半端な状態をyupでバリデーションすることができず、正しくバリデーションできない問題が起きます。 なので、この中途半端な状態をどう親コンポーネントに伝えるかが鍵になります。
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側で判断してもらうのが一つ目の方法です。
// 変更の無いコードは一部省略
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になってしまうなどを防げるなど)
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を使わず直接エラーをセットする方法になります。
// 変更の無いコードは一部省略
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('数字を入力してください');
+ }
}}
/>
);
};
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オブジェクトに入力中のテキストデータも含めることで、ローカル変数を持たずに入力中のデータを親が持つことができます。
+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);
}}
/>
);
};
// 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