Open17

react-hook-formのコードを読む2024/11/07

TamtamTamtam
  • useForm
    • 基本形なので全て抑える
  • useFieldArray
    • なぜ[]stringはうまく受け取れないのか(例えば{ email: string }[]でないといけないのか)
  • Controll
TamtamTamtam

ソースコードの構造を理解

react-hook-form/
├── app/                          # デモ・テスト用アプリケーション
│   ├── src/                      
│   │   ├── app.tsx              # メインアプリ
│   │   ├── basic.tsx            # 基本的な使用例
│   │   ├── useFieldArray.tsx    # フィールド配列のデモ
│   │   └── useWatch.tsx         # Watch機能のデモ
│
├── cypress/                      # E2Eテスト
│   ├── integration/             # テストケース
│   └── support/                 # テストヘルパー
│
├── src/                         # メインのソースコード
│   ├── __tests__/              # ユニットテスト
│   ├── logic/                  # コアロジック
│   ├── types/                  # 型定義
│   │   ├── form.ts
│   │   ├── fields.ts
│   │   └── path/
│   ├── utils/                  # ユーティリティ関数
│   ├── useForm.ts             # メインのフォームフック
│   ├── useFieldArray.ts       # 配列フィールド用フック
│   └── useWatch.ts            # 値監視用フック
│
└── docs/                       # ドキュメント
    ├── README.ja-JP.md        # 日本語ドキュメント
    └── README.zh-CN.md        # 中国語ドキュメント
TamtamTamtam

useForm

export function useForm<
  TFieldValues extends FieldValues = FieldValues,  // ①
  TContext = any,                                  // ②
  TTransformedValues extends FieldValues | undefined = undefined, // ③
>(
  props: UseFormProps<TFieldValues, TContext> = {}, // ④
): UseFormReturn<TFieldValues, TContext, TTransformedValues> // ⑤

ジェネリックパラメータ

  1. TFieldValues:
  • フォームのフィールド値の型を定義
  • FieldValues(オブジェクト型)を拡張する必要がある
  • デフォルトはFieldValues
// 使用例
interface FormInputs {
  email: string;
  password: string;
}

useForm<FormInputs>()  // フォームの型を指定
  1. TContext:
  • フォームのコンテキスト情報の型
  • デフォルトはany
  • バリデーションコンテキストなどで使用
  1. TTransformedValues:
  • 変換後のフォーム値の型
  • オプショナル(undefined可)
  • フォーム送信時の値変換に使用

引数とリターン値

  1. props: UseFormProps:
  • フォームの設定オプション
  • デフォルト値、バリデーションモード、エラーなどを含む
{
  defaultValues?: TFieldValues;
  errors?: FieldErrors;
  disabled?: boolean;
  // ...その他の設定
}
  1. UseFormReturn:
  • フォームの操作に必要なメソッドと状態を返す
{
  register: FieldRegister;
  handleSubmit: SubmitHandler;
  formState: FormState;
  // ...その他のメソッドと状態
}

初期状態の設定

const [formState, updateFormState] = React.useState<FormState<TFieldValues>>({
  isDirty: false,
  isValidating: false,
  isLoading: isFunction(props.defaultValues),
  isSubmitted: false,
  // ...その他の状態
});
function Form() {
  const { handleSubmit, formState: { isSubmitting } } = useForm();
  
  return (
    <form onSubmit={handleSubmit(async (data) => {
      await submitData(data);  // この間isSubmitting = true
    })}>
      <button disabled={isSubmitting}>
        {isSubmitting ? '送信中...' : '送信'}
      </button>
    </form>
  );
}
TamtamTamtam

createSubject.tsの実装とuseFormとの関係を説明します:

1. Subject(観察者パターン)の基本構造

type Subject<T> = {
  readonly observers: Observer<T>[];  // 監視者のリスト
  subscribe: (value: Observer<T>) => Subscription;  // 監視の登録
  unsubscribe: Noop;  // すべての監視を解除
} & Observer<T>;  // 値の更新機能

2. 主な機能

  1. subscribe(監視の登録):
const subscribe = (observer: Observer<T>): Subscription => {
  _observers.push(observer);
  return {
    unsubscribe: () => {
      _observers = _observers.filter((o) => o !== observer);
    },
  };
};
  1. next(値の更新):
const next = (value: T) => {
  for (const observer of _observers) {
    observer.next && observer.next(value);
  }
};

useFormとの関係

useFormでは、このSubjectを以下のような用途で使用しています:

  1. フォームの状態管理:
1717
  it('should unsubscribe to all subject when hook unmounts', () => {
    let tempControl: any;

    const App = () => {
      const { control } = useForm();
      tempControl = control;

      return null;
    };

    const { unmount } = render(<App />);

    expect(tempControl._subjects.state.observers.length).toBeTruthy();

    unmount();

    expect(tempControl._subjects.state.observers.length).toBeFalsy();
  });

このテストは、フォームのアンマウント時に適切に監視が解除されることを確認しています。

  1. フィールドの値の監視:
480
  it('should remove input value after input is unmounted with shouldUnregister: true', () => {
    const watched: unknown[] = [];
    const App = () => {
      const [show, setShow] = React.useState(true);
      const { watch, register } = useForm({
        shouldUnregister: true,
      });

      watched.push(watch());

      return (
        <div>
          {show && <input {...register('test')} />}
          <button
            onClick={() => {
              setShow(false);
            }}
          >
            toggle
          </button>
        </div>
      );
    };

    render(<App />);

    expect(watched).toEqual([{}]);

    fireEvent.change(screen.getByRole('textbox'), {
      target: {
        value: '1',
      },
    });

    expect(watched).toEqual([
      {},
      {
        test: '1',
      },
    ]);

    fireEvent.click(screen.getByRole('button'));

    expect(watched).toEqual([
      {},
      {
        test: '1',
      },
      {
        test: '1',
      },
      {},
    ]);
  });

watch関数は内部でSubjectを使用して値の変更を監視しています。

  1. フォーム状態の購読:
45
/**
 * This custom hook allows you to subscribe to each form state, and isolate the re-render at the custom hook level. It has its scope in terms of form state subscription, so it would not affect other useFormState and useForm. Using this hook can reduce the re-render impact on large and complex form application.
 *
 * @remarks
 * [API](https://react-hook-form.com/docs/useformstate) • [Demo](https://codesandbox.io/s/useformstate-75xly)
 *
 * @param props - include options on specify fields to subscribe. {@link UseFormStateReturn}
 *
 * @example
 * ```tsx
 * function App() {
 *   const { register, handleSubmit, control } = useForm({
 *     defaultValues: {
 *     firstName: "firstName"
 *   }});
 *   const { dirtyFields } = useFormState({
 *     control
 *   });
 *   const onSubmit = (data) => console.log(data);
 *
 *   return (
 *     <form onSubmit={handleSubmit(onSubmit)}>
 *       <input {...register("firstName")} placeholder="First Name" />
 *       {dirtyFields.firstName && <p>Field is dirty.</p>}
 *       <input type="submit" />
 *     </form>
 *   );
 * }
 * ```
 */

useFormStateはSubjectを使用してフォームの状態変更を監視します。

実際の使用例

// useForm内部での使用例
function useForm() {
  const _subjects = {
    watch: createSubject(),     // 値の変更監視用
    state: createSubject(),     // フォーム状態監視用
    array: createSubject(),     // フィールド配列監視用
  };

  // コンポーネントのアンマウント時にクリーンアップ
  useEffect(() => {
    return () => {
      _subjects.watch.unsubscribe();  // 監視を解除
      _subjects.state.unsubscribe();
      _subjects.array.unsubscribe();
    };
  }, []);

  // ...
}

このように、Subjectパターンを使用することで:

  • フォームの状態変更を効率的に監視
  • 必要なコンポーネントだけを再レンダリング
  • メモリリークを防止
  • 複雑な状態管理を簡潔に実装

が可能になっています。

TamtamTamtam
// フォームの値を監視するための実装
const createFormSubscription = () => {
  const observers = [];
  
  return {
    // 値の変更を通知
    notifyChange: (newValue) => {
      observers.forEach(observer => {
        observer.next(newValue);
      });
    },
    
    // 監視を登録
    subscribe: (callback) => {
      const observer = {
        next: callback
      };
      observers.push(observer);
      
      // 購読解除用の関数を返す
      return () => {
        const index = observers.indexOf(observer);
        observers.splice(index, 1);
      };
    }
  };
};

// 使用例
const subscription = createFormSubscription();

// 値の変更を監視
const unsubscribe = subscription.subscribe((newValue) => {
  console.log('値が変更されました:', newValue);
});

// フォームの値が変更された時
subscription.notifyChange({ field: 'newValue' });

// コンポーネントのアンマウント時
unsubscribe();
TamtamTamtam

useSubscribeでフィールドを登録させるためのnextを追加

import React from 'react';

import { Subject } from './utils/createSubject';

type Props<T> = {
  disabled?: boolean;
  subject: Subject<T>;
  next: (value: T) => void;
};

export function useSubscribe<T>(props: Props<T>) {
  const _props = React.useRef(props);
  _props.current = props;

  React.useEffect(() => {
    const subscription =
      !props.disabled &&
      _props.current.subject &&
      _props.current.subject.subscribe({
        next: _props.current.next,
      });

    return () => {
      subscription && subscription.unsubscribe();
    };
  }, [props.disabled]);
}

// 1. Subjectの作成
const subject = createSubject();

// 2. useSubscribeでの監視
function FormComponent() {
  useSubscribe({
    // 監視を無効にするかどうか
    disabled: false,
    
    // 監視対象のSubject
    subject: subject,
    
    // 値が変更されたときのコールバック
    next: (newValue) => {
      console.log('値が変更されました:', newValue);
      // 必要な処理(状態更新など)
    }
  });

  return <form>...</form>;
}

// 3. 値の更新
subject.next(newValue); // これにより、登録された全てのnext関数が呼び出される
TamtamTamtam

useFormの場合


  useSubscribe({
    subject: control._subjects.state,
    next: (
      value: Partial<FormState<TFieldValues>> & { name?: InternalFieldName },
    ) => {
      if (
        shouldRenderFormState(
          value,
          control._proxyFormState,
          control._updateFormState,
          true,
        )
      ) {
        updateFormState({ ...control._formState });
      }
    },
  });

  React.useEffect(
    () => control._disableForm(props.disabled),
    [control, props.disabled],
  );

value: Partial<FormState<TFieldValues>> & { name?: InternalFieldName }の理解

// Partialの定義
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// FormStateに適用すると
type PartialFormState = Partial<FormState<TFieldValues>>;

// 具体例
const partialState: PartialFormState = {
  isDirty: true,
  // すべてのプロパティがオプショナルになる
  // isValid, errors などは省略可能
};

// フォームの値の型定義例
type UserFormValues = {
  email: string;
  password: string;
};

// FormStateの具体例
type UserFormState = FormState<UserFormValues>;

const formState: UserFormState = {
  isDirty: false,
  isValid: true,
  errors: {
    email: { type: 'required', message: '必須です' }
  },
  // ... 他のFormStateプロパティ
};

// 抜粋
export type FormState<TFieldValues extends FieldValues> = {
  isDirty: boolean;
  isLoading: boolean;
  isSubmitted: boolean;
  isSubmitSuccessful: boolean;
  isSubmitting: boolean;
  isValidating: boolean;
  isValid: boolean;
  disabled: boolean;
  submitCount: number;
  defaultValues?: undefined | Readonly<DeepPartial<TFieldValues>>;
  dirtyFields: Partial<Readonly<FieldNamesMarkedBoolean<TFieldValues>>>;
  touchedFields: Partial<Readonly<FieldNamesMarkedBoolean<TFieldValues>>>;
  validatingFields: Partial<Readonly<FieldNamesMarkedBoolean<TFieldValues>>>;
  errors: FieldErrors<TFieldValues>;
};
TamtamTamtam

フィールドの登録と値の監視の流れ

setされるcreateFormControl部分

// useFormの内部実装(簡略化)
function useForm() {
  const register = (name: string) => {
    // フィールドを登録
    const field = {
      name,
      ref: (element: HTMLInputElement) => {
        // イベントリスナーの設定
        element.addEventListener('input', () => {
          const value = element.value;
          // 値の変更を通知
          _subjects.values.next({
            name,
            values: { [name]: value }
          });
        });

        element.addEventListener('blur', () => {
          // タッチ状態の更新を通知
          _subjects.state.next({
            name,
            touchedFields: { [name]: true }
          });
        });
      }
    };

    return field;
  };

  return { register };
}


// フォームの値を更新する関数(setValue)
const setValue = (name: string, value: any) => {
  // フォームの値を更新
  _formValues[name] = value;

  // 監視している対象に通知
  _subjects.values.next({
    name,
    values: _formValues
  });

  // フォームの状態も更新
  _subjects.state.next({
    name,
    isDirty: true,
    dirtyFields: { [name]: true }
  });
};

control._subjects.stateの初期化はcreateFormControl関数内で行われています

TFieldValuesの型パラメータはcreateSubjectの型引数として渡され、Subjectが扱う値の型を制限します。

具体的な流れを見てみましょう:

  1. まずSubjects型の定義:
type Subjects<TFieldValues> = {
  values: Subject<{
    name?: string;
    values: TFieldValues;
  }>;
  array: Subject<{
    name?: string;
    values?: TFieldValues;
  }>;
  state: Subject<{
    name?: string;
  } & Partial<FormState<TFieldValues>>>;
};
  1. createSubjectの実装:
export default <T>(): Subject<T> => {
  let _observers: Observer<T>[] = [];

  const next = (value: T) => {
    for (const observer of _observers) {
      observer.next(value);
    }
  };

  return {
    get observers() {
      return _observers;
    },
    next,
    subscribe: (observer: Observer<T>) => {
      _observers.push(observer);
      return {
        unsubscribe: () => {
          _observers = _observers.filter((o) => o !== observer);
        },
      };
    },
    unsubscribe: () => {
      _observers = [];
    },
  };
};
  1. 使用例:
313
    const Test = ({ control }: { control: Control<FormValues> }) => {
      const { errors } = useFormState({
        control,
        name: 'firstName',
      });

      count++;

      return <>{errors?.firstName?.message}</>;
    };

このコードでは、Control<FormValues>として型が指定され、その結果:

  • _subjects.valuesFormValues型の値の変更を通知
  • _subjects.stateFormState<FormValues>型の状態変更を通知
  • _subjects.array は配列操作に関するFormValues型の変更を通知

このように、TFieldValuesを指定することで、各Subjectが扱うデータの型が適切に制限され、型安全性が確保されます。

TamtamTamtam

useFieldArray

配列要素がオブジェクト構造である必要がある理由は、FieldArrayPathの型定義に関係しています。

まず、useFieldArrayの型パラメータを見てみましょう:

27
export type UseFieldArrayProps<
  TFieldValues extends FieldValues = FieldValues,
  TFieldArrayName extends
    FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>,
  TKeyName extends string = 'id',
> = {
  name: TFieldArrayName;
  keyName?: TKeyName;
  control?: Control<TFieldValues>;
  rules?: {
    validate?:
      | Validate<FieldArray<TFieldValues, TFieldArrayName>[], TFieldValues>
      | Record<
          string,
          Validate<FieldArray<TFieldValues, TFieldArrayName>[], TFieldValues>
        >;
  } & Pick<
    RegisterOptions<TFieldValues>,
    'maxLength' | 'minLength' | 'required'
  >;
  shouldUnregister?: boolean;
};

ここで重要なのはTFieldArrayName extends FieldArrayPath<TFieldValues>の部分です。

FieldArrayPathは配列型のパスを抽出する型で、以下のような構造を想定しています:

// OK: オブジェクト配列
type FormValues = {
  emails: { email: string }[];  // ✅ パスは 'emails.0.email'
}

// NG: プリミティブ配列
type FormValues = {
  emails: string[];  // ❌ パスは 'emails.0' で終わってしまう
}

これには2つの理由があります:

  1. パス解決の一貫性:
  • オブジェクト配列の場合: emails.0.emailのように、各要素のプロパティまでパスが解決できる
  • プリミティブ配列の場合: emails.0で終わってしまい、それ以上のパス解決ができない
  1. フィールド登録の仕組み:
3703
  describe('with nested field array ', () => {
    type FormValues = {
      fieldArray: {
        value: string;
        nestedFieldArray: {
          value: string;
        }[];
      }[];
    };

    const ArrayField = ({
      arrayIndex,
      register,
      control,
    }: {
      arrayIndex: number;
      register: UseFormReturn<FormValues>['register'];
      arrayField: Partial<FieldValues>;
      control: Control<FormValues>;
    }) => {
      const { fields, append } = useFieldArray({
        name: `fieldArray.${arrayIndex}.nestedFieldArray` as const,
        control,
        rules: {
          required: 'This is required',
          minLength: {
            value: 3,
            message: 'Min length of 3',
          },
        },
      });

      return (
        <div>
          {fields.map((nestedField, index) => (
            <div key={nestedField.id}>
              <input
                {...register(
                  `fieldArray.${arrayIndex}.nestedFieldArray.${index}.value` as const,
                )}
              />
            </div>
          ))}
          <button
            type="button"
            onClick={() => {
              append({
                value:
                  `fieldArray.${arrayIndex}.nestedFieldArray.${fields.length}.value` as const,
              });
            }}
          >
            Add nested array
          </button>
        </div>
      );
    };

このテストケースが示すように、各配列要素のプロパティに対して個別のregisterが必要です。プリミティブ配列では、この登録の仕組みが機能しません。

つまり、React Hook Formのフィールドパス解決システムと登録の仕組みが、オブジェクト構造を前提として設計されているためです。


useFieldArrayの実装を見ると、デフォルトのTKeyName'id'として設定されています:

88
export function useFieldArray<
  TFieldValues extends FieldValues = FieldValues,
  TFieldArrayName extends
    FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>,
  TKeyName extends string = 'id',
>(
  props: UseFieldArrayProps<TFieldValues, TFieldArrayName, TKeyName>,
): UseFieldArrayReturn<TFieldValues, TFieldArrayName, TKeyName> {

そして、実際のキー生成は以下の部分で行われています:

425
    fields: React.useMemo(
      () =>
        fields.map((field, index) => ({
          ...field,
          [keyName]: ids.current[index] || generateId(),
        })) as FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>[],
      [fields, keyName],
    ),

このコードでは、各フィールドに対して:

  1. デフォルトのkeyName'id'
  2. ids.current[index]が存在しない場合はgenerateId()で新しいIDを生成
  3. 生成されたIDは[keyName]: generatedIdの形で各フィールドに追加

実際の使用例を見てみましょう:

// デフォルトの場合(TKeyName = 'id')
const { fields } = useFieldArray({
  control,
  name: 'test'
}); 
// fields: [{ id: 'generated-id-1', ...rest }, { id: 'generated-id-2', ...rest }]

// カスタムkeyNameの場合
const { fields } = useFieldArray({
  control,
  name: 'test',
  keyName: 'customId'
}); 
// fields: [{ customId: 'generated-id-1', ...rest }, { customId: 'generated-id-2', ...rest }]

このユニークなIDは、Reactのリストレンダリングにおけるkeyプロパティとして使用され、配列要素の効率的な更新と再レンダリングを可能にします。

TamtamTamtam

なぜ[]stringはうまく受け取れないのか(例えば{ email: string }[]でないといけないのか

FieldArrayPathがは配列型のパスを抽出する型だが、その各要素はオブジェクト構造を前提としている。

export type FieldArrayPath<TFieldValues extends FieldValues> =
  ArrayPath<TFieldValues>;

オブジェクト構造だからこそ、スプレッド演算子(...field)を使って既存のフィールドを展開し、新しいプロパティ([keyName]: id)を追加できるがプリミティブだと機能しない。

例えば:

// ✅ オブジェクト配列の場合
const field = { name: 'John' };
const fieldWithId = {
  ...field,
  id: 'generated-id'
}; // { name: 'John', id: 'generated-id' }

// ❌ プリミティブ配列の場合
const field = 'John';
const fieldWithId = {
  ...field,
  id: 'generated-id'
}; // スプレッド演算子が機能しない

これはuseFieldArrayの実装の中で重要な部分です:

425
    fields: React.useMemo(
      () =>
        fields.map((field, index) => ({
          ...field,
          [keyName]: ids.current[index] || generateId(),
        })) as FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>[],
      [fields, keyName],
    ),

このコードブロックは、各フィールドに一意のIDを付与する処理を行っていますが、これはフィールドがオブジェクト構造であることを前提としています。プリミティブ値の場合、このような拡張ができないため、フィールドの識別や追跡が困難になります。


TypeScriptの型システムと実際のJavaScriptの実行時の動作は異なるためです。

実際の実行時には:

// プリミティブ値でも、JavaScriptは以下のような変換を試みます
const field = "John";
const fieldWithId = {
  ...field,  // プリミティブ値をオブジェクトとして展開しようとする
  id: 'generated-id'
}

// 結果:
console.log(fieldWithId); // { id: 'generated-id' } 
// 元の値"John"は失われる

このため:

  1. プログラムは実行時エラーにはならない
  2. しかし、期待した動作(元の値の保持)はできない

実際のuseFieldArrayの使用例を見てみると:

app/src/useFieldArrayNested.tsx
{fields.map((item, i) => (
  <Controller
    key={item.id}  // ← idは存在するが
    render={({ field }) => <input {...field} aria-label={'name'} />}
    name={`test.${index}.keyValue.${i}.name`}  // ← 元の値にアクセスできない
    control={control}
  />
))}

このコードは:

  • item.idは存在する(追加されたため)
  • しかし元のフィールドの値は失われている

という状態で動作することになります。

つまり、TypeScriptの型チェックでは捕捉できるエラーですが、実行時には"静かに失敗"する形になります。これは特にデバッグを困難にする可能性があるため、型システムで防ぐことが推奨されています。

TamtamTamtam

これはcontrol._getFieldArray(name)の結果によるものです。このメソッドは、フォームの値から指定されたnameのパスに対応する配列を取得します。

例えば:

// フォームの値
{
  test: [
    { email: "test1@example.com" },
    { email: "test2@example.com" }
  ]
}

// useFieldArrayの呼び出し
const { fields } = useFieldArray({
  name: "test",
  control
});

// fieldsの中身
[
  { id: "generated-1", email: "test1@example.com" },
  { id: "generated-2", email: "test2@example.com" }
]

これはuseFieldArray内部で以下のような処理が行われているためです:

201
  const remove = (index?: number | number[]) => {
    const updatedFieldArrayValues: Partial<
      FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>
    >[] = removeArrayAt(control._getFieldArray(name), index);
    ids.current = removeArrayAt(ids.current, index);
    updateValues(updatedFieldArrayValues);
    setFields(updatedFieldArrayValues);
    control._updateFieldArray(name, updatedFieldArrayValues, removeArrayAt, {
      argA: index,
    });
  };

control._getFieldArray(name)は配列内のオブジェクトの構造をそのまま保持したまま返し、それにidが付与される形になります。つまり、元の配列の各要素のプロパティ(この場合はemail)は保持されたままです。

TamtamTamtam

Controller, control

https://react-hook-form.com/docs/usecontroller/controller

このコードは、React を使用してフォームを作成し、react-hook-formReact DatePicker を組み合わせて日付選択機能を実装しています。特に、Controller コンポーネントの render 関数部分についての疑問があるとのことなので、詳しく解説します。

全体の流れ

  1. 必要なライブラリのインポート

    import ReactDatePicker from "react-datepicker"
    import { TextField } from "@material-ui/core"
    import { useForm, Controller } from "react-hook-form"
    
  2. フォームの値の型定義

    type FormValues = {
      ReactDatepicker: string
    }
    
  3. フォームコンポーネントの定義

    function App() {
      const { handleSubmit, control } = useForm<FormValues>()
    
      return (
        <form onSubmit={handleSubmit((data) => console.log(data))}>
          <Controller
            control={control}
            name="ReactDatepicker"
            render={({ field: { onChange, onBlur, value, ref } }) => (
              <ReactDatePicker
                onChange={onChange} // フォームの状態を更新
                onBlur={onBlur}     // フォームのタッチ状態を通知
                selected={value}    // フォームの現在の値を設定
              />
            )}
          />
    
          <input type="submit" />
        </form>
      )
    }
    

詳細な解説

1. useForm フックの使用

const { handleSubmit, control } = useForm<FormValues>()
  • useFormreact-hook-form のフックで、フォームの状態管理を行います。
  • handleSubmit はフォーム送信時の処理をラップする関数です。
  • control はフォームコントロールを管理するオブジェクトで、Controller コンポーネントに渡します。

2. Controller コンポーネント

<Controller
  control={control}
  name="ReactDatepicker"
  render={({ field: { onChange, onBlur, value, ref } }) => (
    <ReactDatePicker
      onChange={onChange}
      onBlur={onBlur}
      selected={value}
    />
  )}
/>

Controller は、react-hook-form がネイティブにサポートしていないカスタムコンポーネント(この場合は ReactDatePicker)をフォームに統合するためのコンポーネントです。

  • control: useForm から取得したコントロールオブジェクトを渡します。
  • name: フォームデータ内のこのフィールドのキー名を指定します。
  • render: 実際にフォームフィールドをレンダリングするための関数を提供します。

3. render 関数の内部

render={({ field: { onChange, onBlur, value, ref } }) => (
  <ReactDatePicker
    onChange={onChange} // フォームの状態を更新
    onBlur={onBlur}     // フォームのタッチ状態を通知
    selected={value}    // フォームの現在の値を設定
  />
)}

render 関数は、Controller が管理するフィールドに必要なプロパティを ReactDatePicker に渡すために使用されます。この関数の引数として渡されるオブジェクトには、フィールドの状態やイベントハンドラーが含まれています。ここでは、以下のプロパティを ReactDatePicker に渡しています:

  • onChange: ユーザーが日付を選択したときに呼び出され、フォームの状態を更新します。
  • onBlur: フィールドがフォーカスを失ったときに呼び出され、フォームがフィールドの「タッチ」状態を認識します。
  • value: 現在のフィールドの値を ReactDatePicker に渡します。
  • ref: フィールドの参照ですが、この例では使用されていません。

これにより、ReactDatePickerreact-hook-form のフォーム状態と連携し、選択された日付がフォームデータとして管理されます。

4. フォームの送信

<form onSubmit={handleSubmit((data) => console.log(data))}>
  ...
  <input type="submit" />
</form>
  • handleSubmit がフォームの送信を処理し、バリデーションを行った後、データをコンソールに出力します。

その他のポイント

  • TextField のインポートについて:

    import { TextField } from "@material-ui/core"
    

    このコードでは TextField がインポートされていますが、実際には使用されていません。不要であれば削除することをお勧めします。

  • 型の一致について:

    type FormValues = {
      ReactDatepicker: string
    }
    

    ReactDatePicker は通常、Date オブジェクトを扱いますが、ここでは string 型として定義されています。実際の使用においては型の不一致が問題になる可能性があるため、必要に応じて型を調整してください。

    例えば、以下のように Date 型に変更することができます:

    type FormValues = {
      ReactDatepicker: Date
    }
    

まとめ

  • Controller コンポーネントは、react-hook-form とカスタムコンポーネント(この場合は ReactDatePicker)を連携させるために使用されます。
  • render 関数内で渡される field のプロパティ(onChange, onBlur, value, ref)は、react-hook-form が管理するフォームの状態とイベントをカスタムコンポーネントに適切に伝えるためのものです。
  • これにより、ReactDatePickerreact-hook-form の一部として動作し、フォームの状態管理やバリデーションがシームレスに行われます。

このようにして、react-hook-form を使用することで、フォームの状態管理が簡潔かつ効率的に行えるようになります。

TamtamTamtam

controller.tsにて、使えるpropがわかる

//controller.ts
 */
export type ControllerProps<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
  render: ({
    field,
    fieldState,
    formState,
  }: {
    field: ControllerRenderProps<TFieldValues, TName>;
    fieldState: ControllerFieldState;
    formState: UseFormStateReturn<TFieldValues>;
  }) => React.ReactElement;
} & UseControllerProps<TFieldValues, TName>;

ControllerRenderProps<TFieldValues, TName>の型パラメータについて説明します。

例えば、以下のコードの場合:

16
type FormValues = {
  test: {
    firstName: string;
    lastName: string;
    keyValue: { name: string }[];
  }[];
};

このようなFormValues型があるとき、Controllerコンポーネントの使用箇所では:

46
        {fields.map((item, i) => (
          <Controller
            key={item.id}
            render={({ field }) => <input {...field} aria-label={'name'} />}
            name={`test.${index}.keyValue.${i}.name`}
            control={control}
          />
        ))}

ここでのTFieldValuesTNameは以下のように解決されます:

  1. TFieldValues = FormValues型:
{
  test: {
    firstName: string;
    lastName: string;
    keyValue: { name: string }[];
  }[];
}
  1. TName = test.${index}.keyValue.${i}.nameという文字列リテラル型

これにより、ControllerRenderPropsの各プロパティは以下のように具体化されます:

{
  onChange: (...event: any[]) => void;
  onBlur: () => void;
  value: string;  // keyValue[i].nameの型
  name: `test.${number}.keyValue.${number}.name`;
  ref: RefCallBack;
  disabled?: boolean;
}

この型パラメータにより、フォームの値の型安全性が保証され、特にvalueの型が正しく推論されます。

TamtamTamtam

以下の流れで値が更新されます:

  1. useControllerフックでfieldオブジェクトが生成されます:
143
      onChange: React.useCallback(
        (event) =>
          _registerProps.current.onChange({
            target: {
              value: getEventValue(event),
              name: name as InternalFieldName,
            },
            type: EVENTS.CHANGE,
          }),
        [name],
      ),
  1. このonChangeハンドラは_registerProps.current.onChangeを呼び出し、これはcontrol.registerによって提供されます:
80
  const _registerProps = React.useRef(
    control.register(name, {
      ...props.rules,
      value,
      ...(isBoolean(props.disabled) ? { disabled: props.disabled } : {}),
    }),
  );
  1. <input {...field}で展開されると、以下のプロパティが入力要素に適用されます:
33
export type ControllerRenderProps<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
  onChange: (...event: any[]) => void;
  onBlur: Noop;
  value: FieldPathValue<TFieldValues, TName>;
  disabled?: boolean;
  name: TName;
  ref: RefCallBack;
};

つまり:

  1. ユーザーが入力を行う
  2. inputのonChangeイベントが発火
  3. field.onChangeが呼び出される
  4. _registerProps.current.onChangeが実行され、フォームの値が更新される
  5. useWatchuseFormStateなどを通じて、更新された値が反映される

このプロセスは全て自動的に行われ、開発者が明示的にハンドリングする必要はありません。