ymmr
Recent Articles
    公開日: 2024/01/06

    React Hook Form の外からフィールドの値を更新する

    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 に関する情報が見つかりにくい気もするので、今後も何かあれば発信していきたいと思います。