React Hook Form とフィールドの値を連動する際、MUI のようなライブラリは Controller を使い、それ以外には register を使うという認識でいました。今回はフォームの外からフィールドの値を更新する方法を学んだので、その内容を記事にします。
やりたいこと
MUI が提供している Number Input のようなコンポーネントを自作して、それを React Hook Form で制御するフォーム内で利用したいと考えました。イメージとしてはこんな感じです。
useForm の API を確認する
フォームの外からフィールドの値を更新するには、何らかの方法で React Hook Form が制御するフィールドの状態にアクセスする必要があります。useForm の API を確認したところ、getValues
でフィールドの値を取得して、setValue
で値を更新できそうなことがわかりました。
function Component() {
const { getValues, setValue } = useForm<{ name: string, age: number}>();
const values = getValues(); // { name: 'Bob', age: 20 }
setValue('age', 25);
}
実装
Number Input コンポーネントからつくります。ポイントとして、以下の 2 点を意識しました。
- Props に
React.ComponentPropsWithRef<'input'>
を使うことで、register
を返り値をそのまま受け取れるようにする - input の値を React Hook Form で制御できるように、ref をフォワーディングする
/components/NumberInput.tsx
import { forwardRef } from 'react';
type Props = React.ComponentPropsWithRef<'input'> & {
decrement: () => void;
increment: () => void;
};
const NumberInput = forwardRef<HTMLInputElement, Props>(function NumberInput(
{ decrement, increment, ...rest },
ref,
) {
return (
<div>
<button type="button" onClick={decrement}>
-
</button>
<input
ref={ref}
{...rest}
/>
<button type="button" onClick={increment}>
+
</button>
</div>
);
});
export default NumberInput;
続いて、フォームと React Hook Form を結び付けるカスタムフックをつくります。
/hooks/useProfileForm.ts
import { useForm } from 'react-hook-form';
import type { UseFormRegisterReturn } from 'react-hook-form';
// フォームのフィールドを表現する型
type ProfileFormType = {
name: string;
age: number;
};
// Props としてフォームの初期値を受け取る
type Props = {
defaultValues: ProfileFormType;
};
// フォームに注入する register の返り値をまとめる
type FormRegisters = {
[P in keyof ProfileFormType]: UseFormRegisterReturn<P>;
};
export const useProfileForm = ({ defaultValues }: Props) => {
// useForm から getValues と setValue を受け取る
const { register, getValues, setValue, handleSubmit } =
useForm<ProfileFormType>({
defaultValues,
});
const formRegisters: FormRegisters = {
name: register('name'),
age: register('age'),
};
const decrementAge = () => {
const currentAge = getValues('age');
// ^^^^^ age フィールドの値を number 型として取得できる
setValue('age', currentAge - 1);
// ^^^^^ age フィールドの値を更新する
};
const incrementAge = () => {
const currentAge = getValues('age');
setValue('age', currentAge + 1);
};
return {
formRegisters,
decrementAge,
incrementAge,
handleSubmit,
};
};
上記のカスタムフックは "フォームと React Hook Form をくっつける糊" のように機能します。仕上げとして、useProfileForm
の返り値とフォームを組み合わせて完成です。
/pages/index.tsx
import NumberInput from '@/components/NumberInput';
import { useProfileForm } from '@/hooks/useProfileForm';
const Page = () => {
const { formRegisters, decrementAge, incrementAge, handleSubmit } =
useProfileForm({
defaultValues: {
name: '',
age: 20,
},
});
const submitHandler = handleSubmit((data) => console.log(data));
return (
<form onSubmit={submitHandler}>
<label>
Name
<input {...formRegisters.name} />
</label>
<label>
Age
<NumberInput
decrement={decrementAge}
increment={incrementAge}
{...formRegisters.age}
/>
</label>
<button type="submit">Submit</button>
</form>
);
};
export default Page;
Number Input コンポーネントを値を更新 (年齢を 2 歳上げる) した後、フォームを送信するとしっかり状態が更新されていました!
おまけ
上記の実装では、特定のフォームとカスタムフックが密結合しています。もう少し再利用性の高いものをつくりたかったのですが、TypeScript にうまく型推論させることができませんでした。
/hooks/useNumberInput.ts
import type { FieldPath, FieldValues, UseFormReturn } from 'react-hook-form';
// 値が number 型のフィールドだけを取り出すユーティリティ型
type NumberFieldValues<T extends FieldValues> = {
[P in keyof T]: T[P] extends number ? T[P] : never;
};
// Props として useForm の返り値と、Number Input で使用したいフィールド名を受け取る
type Props<T extends FieldValues> = {
useFormReturn: UseFormReturn<T>;
fieldPath: FieldPath<NumberFieldValues<T>>;
};
export const useNumberInput = <T extends FieldValues>({
useFormReturn,
fieldPath,
}: Props<T>) => {
const { getValues, setValue } = useFormReturn;
const decrement = () => {
const currentValue = getValues(fieldPath);
setValue(fieldPath, currentValue - 1);
// ^^^^^ setValue の第 2 引数が number 型に推論されないため、型エラーとなる
};
return {
decrement,
};
};
おわりに
React Hook Form は提供されている型が難しく、使いこなせている気がしません。TypeScript に関する情報が見つかりにくい気もするので、今後も何かあれば発信していきたいと思います。