はじめに
初めてフロント管理画面の開発でmantine/ui
(かみなり4倍ダメージ)を使い、mantineにformライブラリも備わっていたのでmantine/formのhooksを使ってみました。
過去に筆者はreact-hook-form
を使っていたので、mantine/form
と比較して良かったところや使いづらいところなど、まとめました。
※ 2023/09時点: mantine/form: v6.0.19
Mantine UIライブラリについて
100種類以上のカスタマイズ可能なコンポーネントやhooksを備えているUIライブラリ。
なぜmantine/formを採用したか
今回初めて、mantine/uiを使いformのAPIがあったので互換性があり、yup(バリデーションスキーマ)の対応、react-hook-form
と見たところ使用感が似ていたので今回使ってみました。
1. 非同期のバリデーションに対応していない
まず要件として、画像ファイルのアップロードの画像解像度(縦、横)のバリデーションをフロントで行う必要があった。
画像のアップロードフォームを実装する際に、画像の解像度サイズをチェックしたい場合、image.onLoad()
やfile.onload()
などのPromiseベースでチェックをかけたい時に筆者はyup
を使って非同期のバリデーションを含んだスキーマ定義を行い、いざ使うとしたところmantine/formは対応できていなかった。
実際に出たエラー
yup-resolver.ts:10 Uncaught TypeError: Cannot read properties of undefined (reading 'forEach') at yup-resolver.ts:10:22
export const formSchema: yup.ObjectSchema<FormValues> = yup.object({
name: yup.string().max(100).required(),
file: yup
.mixed<File>()
.required()
.test('file', エラーメッセージ, (value) => value instanceof File)
.test({
name: 'fileResolution',
message: エラーメッセージ,
async (value: File) => {
// valueを元に`image.onLoad`で画像のheight, widthを取得チェック。
return await fileResolution(value)
},
}),
.test('fileByteSize', エラーメッセージ, (value) => value.size <= 1000),
});
上記の形のyupをmantine/formのyupResolver()
に読み込ませるとえらーになりました。
結論: useFormのvalidateにyup(バリデーションスキーマ)を読み込むyupResolver
のメソッドを使って、validate
に読み込ませるのだが内部的に対応していなかった。
因みにreact-hook-formでは、もちろん対応していて内部的に違うのかみてみました。
react-hook-formでyup(validate schema)を使う時は、@hookform
からyupResolver
を使うようになっており、mantine同様、useFormのvalidate
にyupResolver(スキーマ)
を呼ぶことでバリデーションルールを定義できるようです。
その@hookform
の内部を見てみるとデフォルトで非同期のバリデーションをかけるようにしていました。
export declare function yupResolver<TFieldValues extends FieldValues>(schema: Yup.ObjectSchema<TFieldValues>, schemaOptions?: Parameters<(typeof schema)['validate']>[1], resolverOptions?: {
/**
* @default async
*/
mode?: 'async' | 'sync';
/**
* Return the raw input values rather than the parsed values.
* @default false
*/
raw?: boolean;
}): Resolver<TFieldValues>;
対してmantine/form
のyupResolver
内部ソースを除くと以下のようなって、非同期に対応してないように見えました。
import type { FormErrors } from '../../types';
export declare function yupResolver(schema: any): (values: Record<string, any>) => FormErrors;
//# sourceMappingURL=yup-resolver.d.ts.map
去年くらいに対応してくれそうだったが、お蔵入りになっている。
結局このバリデーションをどうしたか
画像解像度のチェックの非同期バリデーションをかけるため専用のvalue(例:imageResolution)
を追加し、ファイルアップロードしたタイミングでimageResolution
に画像解像度を保存させバリデーションをかけるようにしました。
(フォームとしてimageResolution
は表示させてないです。)
コードベース↓
export type FormValues = {
name: string;
file: File | null;
imageResolution?: {
width: number;
height: number;
};
};
const initialValues: FormValues = {
name: '',
file: null,
imageResolution: undefined,
}
export const formSchema: yup.ObjectSchema<FormValues> = yup.object({
name: yup.string().max(100).required(),
file: yup
.mixed<File>()
.required()
.test('file', エラーメッセージ, (value) => value instanceof File)
.test('fileByteSize', エラーメッセージ, (value) => value.size <= 1000),
imageResolution: yup
.object()
.shape({
width: yup.number().required(),
height: yup.number().required(),
})
.test({
name: 'fileResolution',
message: エラーメッセージ,
test({ width, height }, { parent }) {
const formValue: FormValues = parent;
if () {
// ここで画像解像度チェック
}
return true;
},
}),
});
const { getInputProps, onSubmit, isDirty, errors, values, setValues } =
useForm<FormValues>({
validateInputOnBlur: true,
initialValues,
validate: yupResolver(formSchema),
});
//
const handleEvent = useCallback(
async (file: File) => {
// 画像解像度の読み込み
const { width, height } = await loadImage(file);
// 取得できたらセット
setValues({ file, imageResolution: { width, height } });
},
[setValues]
);
2. カスタムのonChange
やonBlur
をやる場合は、自身でvalueを更新する必要がある
react-hook-formのregister
メソッドには、optionでonChangeやonBlueがあるのでカスタムのイベントをそのoptionに渡せば、valueの更新とは違う処理が追加できる。
それに対して、mantine/formのgetInputProps
はそのoption引数がなく、現状inputのtypeをinput or checkbox
にするかgetInputPropsに含まれるerrorプロパティを受け取るか、などの設定しかできないようです。
参照:https://mantine.dev/form/use-form/#getinputprops
As second parameter options can be passed.
・type: default input. Needs to be configured to checkbox if input requires checked to be set instead of value.
・withError: default type === 'input'. Specifies if the returned object contains an error property with form.errors[path] value.
・withFocus: default true. Specifies if the returned object contains an onFocus handler. If disabled, the touched state of the form can only be used if all values are set with setFieldValue.
ソースコード(getInputPropsの型↓)
export type GetInputProps<Values> = <Field extends LooseKeys<Values>>(path: Field, options?: {
type?: GetInputPropsType;
withError?: boolean;
withFocus?: boolean;
}) => {
value: any;
onChange: any;
checked?: any;
error?: any;
onFocus?: any;
onBlur?: any;
};
返されるobjectもmantine/UIのコンポーネントに合わせているがもう少し型付けをしてほしいところ。
なので、onChange
やonBlur
などのカスタムイベントをする場合は、自身でsetFieldValues
もしくはsetValues
でinput元のvalue値を更新してあげる必要がありました。
3. フォームのレンダリング制御のメソッドがない
react-hook-formにはuseWatch()
を使うことで、コンポーネントの再描画の範囲を制御することが可能だが、mantine/form
にはそういったメソッドや制御を備えているわけではなく、一つの入力フォームをレンダリングすると全体の入力フォームが再描画してしまうのでフォームの数が多い場合は再描画の注意が必要そうです。
因みに、useFormで使えるメソッドはこちら
export interface UseFormReturnType<Values, TransformValues extends _TransformValues<Values> = (values: Values) => Values> {
values: Values;
errors: FormErrors;
setValues: SetValues<Values>;
setErrors: SetErrors;
setFieldValue: SetFieldValue<Values>;
setFieldError: SetFieldError<Values>;
clearFieldError: ClearFieldError;
clearErrors: ClearErrors;
reset: Reset;
validate: Validate;
validateField: ValidateField<Values>;
reorderListItem: ReorderListItem<Values>;
removeListItem: RemoveListItem<Values>;
insertListItem: InsertListItem<Values>;
getInputProps: GetInputProps<Values>;
onSubmit: OnSubmit<Values, TransformValues>;
onReset: OnReset;
isDirty: GetFieldStatus<Values>;
isTouched: GetFieldStatus<Values>;
setTouched: SetFormStatus;
setDirty: SetFormStatus;
resetTouched: ResetStatus;
resetDirty: ResetDirty<Values>;
isValid: IsValid<Values>;
getTransformedValues: GetTransformedValues<Values, TransformValues>;
}
mantine/ui
だからformの良いところ
バリデーションエラーのメッセージを表示させるテキストを用意しなくてもmantineのカスタムコンポーネントの中にerror
propsが内蔵されているので、{...getInputProps()}
を渡すだけで簡潔できる。
<Input {...register("name", {required: {value: true, message: "nameError"}})} />
{errors.name && errors.name.message} // 定義する必要がある。
<TextInput
{...form.getInputProps('name')}
/>
カスタムコンポーネントの作りによると思うが、例えばreact-selectやchakra uiのSelect
コンポーネントをreact-hook-formと組み合わせる時はController
を使わないといけない事に対して、mantine UIはselect
コンポーネントも以下のように渡すことができるので、簡潔に済みました。
<Controller
control={control}
name="food"
rules={{ required: "Please enter at least one food group." }}
render={({
field: { onChange, onBlur, value, name, ref },
fieldState: { error }
}) => (
<FormControl py={4} isInvalid={!!error} id="food">
<FormLabel>Food Groups</FormLabel>
<Select
isMulti
name={name}
ref={ref}
onChange={onChange}
onBlur={onBlur}
value={value}
options={foodGroups}
placeholder="Food Groups"
closeMenuOnSelect={false}
/>
<FormErrorMessage>{error && error.message}</FormErrorMessage>
</FormControl>;
)}
/>
<Select
label="オプション"
data={options}
{...getInputProps('options')}
/>
まとめ
以上がmantine/formを数ヶ月使ってみて、mantine/uiを使っていく中でシンプルなフォームをを作成する場合には開発効率には問題ないと思いますが、バリデーションを非同期で行いたい場合やレンダリング制御の考慮も必要な場合は他のライブラリも検討してみると良いかもしれません。