👌

React Hook Form (RHF)に入門してみた

2022/11/21に公開

基本的な使い方

バリデーションを見越してzodで型定義します。

Project.tsx
import { z } from 'zod';
import { useForm, SubmitHandler } from 'react-hook-form';

const projectSchema = z.object({
  projectId: z.number(),
  adminUser: z.object({
    name: z.string(),
  });
});

type Project = z.infer<typeof projectSchema>

const Component: React.FC<{project: Project}> = ({
  project
}) => {
  const { handleSubmit } = useForm<Project>({
    defaultValues: project
  });

  const onSubmit: SubmitHandler<Project>=  data => { console.log(data); };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("projectId")} 
      />
      <input
        {...register("adminUser.name")} 
      />
      <button type="submit" >Submit</button>
    </form>
  );

}

Controlled componentとのインテグレーション

RHFはrefを使ってDOMを直接操作することでレンダリングの影響範囲を最小限にとどめています。
しかし私が使用しているコンポーネントは全てuseStateによって宣言的に管理することが前提となっているため、Contollerを使ってRHFにstateの管理を委ねます。
stateの管理はRHFが担ってくれるので、開発者が意識する必要はありません。
しかもフォームの入力によるレンダリングの影響範囲も最小限にとどめてくれます。

Project.tsx
import { z } from 'zod';
import { useForm, SubmitHandler, Controller } from 'react-hook-form';

const projectSchema = z.object({
  projectId: z.number(),
  adminUser: z.object({
    name: z.string(),
  });
});

type Project = z.infer<typeof projectSchema>

const Component: React.FC<{project: Project}> = ({
  project
}) => {
  const { handleSubmit, control } = useForm<Project>({
    defaultValues: project
  });

  const onSubmit: SubmitHandler<Project>=  data => { console.log(data); };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name={`projectId`}
        control={control}
        render={({ field }) => {
          return (
            <MyOwnInput1
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <Controller
        name={`adminUser.name`}
        control={control}
        render={({ field }) => {
          return (
            <MyOwnInput2
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <button type="submit" >Submit</button>
    </form>
  );

}

zodでバリデーションを実装する

projectSchemazodResolverを組み合わせてバリデーションを実装します。

Project.tsx
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, SubmitHandler, Controller, SubmitErrorHandler } from 'react-hook-form';

const projectSchema = z.object({
  projectId: z.number().min(1),
  adminUser: z.object({
    name: z.string().nonempty({ message: 'Required' }),
  });
});

type Project = z.infer<typeof projectSchema>

const Component: React.FC<{project: Project}> = ({
  project
}) => {
  const { handleSubmit, control } = useForm<Project>({
    defaultValues: project,
    resolver: zodResolver(projectSchema),
  });

  const onSubmit: SubmitHandler<Project>= data => { console.log(data); };
  const onError: SubmitErrorHandler<Project>= errors => { console.log(errors); };

  return (
    <form onSubmit={handleSubmit(onSubmit, onError)}>
      <Controller
        name={`projectId`}
        control={control}
        render={({ field, fieldState: { error } }) => {
          console.log({ error });
          return (
            <MyOwnInput1
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <Controller
        name={`adminUser.name`}
        control={control}
        render={({ field, fieldState: { error } }) => {
          console.log({ error });
          return (
            <MyOwnInput2
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <button type="submit" >Submit</button>
    </form>
  );

}

getValuesでフォームの入力値を任意のタイミングで取得する

getValuesを使うと、任意のタイミングでRHFに委任したstateの値を取得できます。

Project.tsx
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, SubmitHandler, Controller, SubmitErrorHandler } from 'react-hook-form';

const projectSchema = z.object({
  projectId: z.number().min(1),
  adminUser: z.object({
    name: z.string().nonempty({ message: 'Required' }),
  });
});

type Project = z.infer<typeof projectSchema>

const Component: React.FC<{project: Project}> = ({
  project
}) => {
  const { handleSubmit, control, getValues } = useForm<Project>({
    defaultValues: project,
    resolver: zodResolver(projectSchema),
  });

  const onSubmit: SubmitHandler<Project>= data => { console.log(data); };
  const onError: SubmitErrorHandler<Project>= errors => { console.log(errors); };
  const onClick = ()=>{ console.log(getValues()) }

  return (
    <form onSubmit={handleSubmit(onSubmit, onError)}>
      <Controller
        name={`projectId`}
        control={control}
        render={({ field, fieldState: { error } }) => {
          console.log({ error });
          return (
            <MyOwnInput1
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <Controller
        name={`adminUser.name`}
        control={control}
        render={({ field, fieldState: { error } }) => {
          console.log({ error });
          return (
            <MyOwnInput2
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <button type="submit" onClick={onClick}>Submit</button>
    </form>
  );

}

useFieldArrayでlist型のフォームを実装する

テーブルなどのlist型のフォームは管理するstateが肥大化してレンダリング処理も重くなりがちですが、useFieldArrayを使うとフォームの入力によるレンダリングの影響範囲も最小限にとどめることができます。

Project.tsx
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, SubmitHandler, Controller, SubmitErrorHandler, useFieldArray } from 'react-hook-form';

const projectSchema = z.object({
  projectId: z.number().min(1),
  adminUser: z.object({
    name: z.string().nonempty({ message: 'Required' }),
  });
});
const projectsSchema = z.object({
  projects: z.array(projectSchema)
});

type Projects = z.infer<typeof projectsSchema>

const Component: React.FC<{projects: Projects}> = ({
  projects
}) => {
  const { handleSubmit, control, getValues } = useForm<Projects>({
    defaultValues: projects,
    resolver: zodResolver(projectsSchema),
  });
  const { fields } = useFieldArray({
    name: 'projects',
    control
  });

  const onSubmit: SubmitHandler<Projects>= data => { console.log(data); };
  const onError: SubmitErrorHandler<Projects>= errors => { console.log(errors); };
  const onClick = ()=>{ console.log(getValues()) }

  return (
    <form onSubmit={handleSubmit(onSubmit, onError)}>
      <Table>
        <TableHead>
          <TableRow>
            <HeaderCell text={`projectId`} />
            <HeaderCell text={`adminName`} />
          </TableRow>
        </TableHead>
        <TableBody>
          {fields.map((field, index) => (
            <TableRow key={field.id} >
              <Controller
                name={`projects.${index}.projectId`}
                control={control}
                render={({ field, fieldState: { error } }) => {
                  console.log({ index, error });
                  return (
                    <MyOwnInput1
                      value={field.value}
                      onChange={field.onChange}
                    />
                  );
                }}
              />
              <Controller
                name={`projects.${index}.adminUser.name`}
                control={control}
                render={({ field, fieldState: { error } }) => {
                  console.log({ index, error });
                  return (
                    <MyOwnInput2
                      value={field.value}
                      onChange={field.onChange}
                    />
                  );
                }}
              />
            </TableRow>
          ))}
        </TableBody>
      </Table>
      <button type="submit" onClick={onClick}>Submit</button>
    </form>
  );
};

resetを使って明示的にuseFormの初期化を行う

例えばSWRなどでデータフェッチをしていると、キャッシュを更新するため親コンポーネントからPropsが複数回降りてくる可能性があります。
しかし、useFormの初期化はコンポーネントの最初のレンダリング時にしか行われません。
そのため、useFormのdefaultValuesにPropsを利用している場合は、resetを使ってPropsが変更されるたびに明示的にuseFormの初期化をしてあげます。

defaultValues are cached on the first render within the custom hook. If you want to reset the defaultValues, you should use the reset api.
参照

Project.tsx
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, SubmitHandler, Controller, SubmitErrorHandler } from 'react-hook-form';

const projectSchema = z.object({
  projectId: z.number().min(1),
  adminUser: z.object({
    name: z.string().nonempty({ message: 'Required' }),
  });
});

type Project = z.infer<typeof projectSchema>

const Component: React.FC<{project: Project}> = ({
  project
}) => {
  const { handleSubmit, control, getValues, reset } = useForm<Project>({
    defaultValues: project,
    resolver: zodResolver(projectSchema),
  });

  useEffect(() => {
    reset(project);
  }, [reset, project]);

  const onSubmit: SubmitHandler<Project>= data => { console.log(data); };
  const onError: SubmitErrorHandler<Project>= errors => { console.log(errors); };
  const onClick = ()=>{ console.log(getValues()) }

  return (
    <form onSubmit={handleSubmit(onSubmit, onError)}>
      <Controller
        name={`projectId`}
        control={control}
        render={({ field, fieldState: { error } }) => {
          console.log({ error });
          return (
            <MyOwnInput1
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <Controller
        name={`adminUser.name`}
        control={control}
        render={({ field, fieldState: { error } }) => {
          console.log({ error });
          return (
            <MyOwnInput2
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <button type="submit" onClick={onClick}>Submit</button>
    </form>
  );

}

余談:コンポーネントの初期化について

余談ですが、useForm同様、例えばuseStateもコンポーネントの最初のレンダリング時にしか初期化が行われません。そのため、useStateの初期値にPropsの値を利用している場合も、今回同様に初期化をコントロールする必要があります。

上記の例ではuseEffectを使ってuseFormの初期化をおこないましたが、コンポーネントのkeyを利用することも可能です。

Whenever the key (which you’ve set to userId) changes, React will recreate the DOM and reset the state of the Profile component and all of its children. As a result, the comment field will clear out automatically when navigating between profiles
参照

コンポーネントを呼び出す際にkeyを指定する。

const ParentComponent: React.FC<{}> = () => {
  const { data } = useFetchNumber();
  if(!data) return null;
  return (
    <ChildComponent key={JSON.stringify(data)} data={data} />
  );
}

参照

FormProviderでコンテキストを作る

Project.tsx
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, SubmitHandler, Controller, SubmitErrorHandler, FormProvider, useFormContext } from 'react-hook-form';

const projectSchema = z.object({
  projectId: z.number().min(1),
  adminUser: z.object({
    name: z.string().nonempty({ message: 'Required' }),
  });
});

type Project = z.infer<typeof projectSchema>

const ParentComponent: React.FC<{}> = () => {
  const { data: project } = useFetchProjectData();
  const { handleSubmit, getValues, control, reset } = useForm<Project>({
    defaultValues: project,
    resolver: zodResolver(projectSchema),
  });
  useEffect(() => {
    reset(project);
  }, [reset, project]);

  const onSubmit: SubmitHandler<Project>= data => { console.log(data); };
  const onError: SubmitErrorHandler<Project>= errors => { console.log(errors); };

  return (
    <FormProvider {...{ getValues, control, reset }}>
      <form onSubmit={handleSubmit(onSubmit, onError)}>
        <ChildComponent project={{ project }} />
      </form>
    </FormProvider>
  );
}

const ChildComponent: React.FC<{project: Project}> = ({
  project
}) => {
  const { getValues, control, reset } = useFormContext<Project>();  
  const onClick = ()=>{ console.log(getValues()) }
  
  return (
    <>
      <Controller
        name={`projectId`}
        control={control}
        render={({ field, fieldState: { error } }) => {
          console.log({ error });
          return (
            <MyOwnInput1
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <Controller
        name={`adminUser.name`}
        control={control}
        render={({ field, fieldState: { error } }) => {
          console.log({ error });
          return (
            <MyOwnInput2
              value={field.value}
              onChange={field.onChange}
            />
           );
         }}
       />
      <button type="submit" onClick={onClick}>Submit</button>
    </>
  );

}

Discussion