PR

React.js / Next.jsでのReact Hook Formの実装を考える

ALL開発

恐らく、ここ最近のReact.js/Next.jsで作られているもののフォームはほぼほぼReact Hook Formが使われているだろう。zodと組み合わせて堅牢なフォームが作れるのは確かだ。

ただ、使い方は人によってかなり違ってくると思う。なので個人的な備忘録を残しておきます!

【参考サイト】

React Hook Formの初期値に、SWRで取得した値をセットする方法を検証

非同期データを初期値に使う場合どうする?

❌setValue

setValueは初期値を入れる用途では使用しない

🔺resetを使う

defaultValuesより優先される
const { reset } = useForm()
const { data } = useSWR();

useEffect(() => {
	reset(data)
}, [data])

useEffectでreset対象を複数watchする必要があり、一度だけresetしたい場合にrefなどで制御しないといけない

⭕️ valuesを使う

v7からvaluesが登場。defaultValuesより優先される。
const { data: values } = useSWR();

useForm({ 
  values, // valuesは一度しか更新されない
})

useMemoですべてのデータが揃った時だけ値を返し、それ以外の場合はundefinedを返せば意図したデータになる。

defaultValuesと併用する場合にどの最終的に値がどうなるか想定しずらいがcreateValuesなどの関数に初期値生成のロジックをまとめることで解決する。

⭕️ defaultValuesを使う

v7からdefaultValuesにPromiseを渡すことができる。
useForm({ 
  defaultValues: async () => await fetchData()
})

useSWRではpromiseを解決してからdataを返すため相性が悪い。

以下のようにuseSWRMutationを使うとdefaultValuesでpromiseを解決できる。

const { trigger } = useFetchUserByMutation();
  const {
    formState: { isLoading },
    handleSubmit,
    register,
    reset,
  } = useForm({
    defaultValues: async () => {
      const result = await trigger();
      return { ...result };
    },
  });

// useSWR
export const useFetchUserByMutation = () => {
  return useSWRMutation(API_PATH, fetcher);
};

hooksをstate用とhandler用に分けるべきか

メリット

  • 関心の分離
  • ファイルあたりのコード量が減る

デメリット

  • hooks間でisDirtyなどのstateを引き回す必要がある

あまり分けるメリットがなさそう。

コード量が減ることに関して

そもそもコードが多くなる原因はreset部分とsubmit時のparam部分。

createDefaultValuesやcreateRequestParamなどのutilsに切り出す方がより関心も分離し見やすくなる。

tips
mode: ‘onChange’を使わない

入力内容が変化するたびにエラーメッセージに反映され、ユーザー体験が悪い。

isSubmittingを使う

submitボタンのdisabledにisSubmittingを渡し、複数回form内容がリクエストされないようにする。

handleSubmitに渡した関数のpromiseが完了するまでtrueになる。

const { handleSubmit, formState: { isSubmitting } } = useForm();
resolverを使う
useForm({
  resolver: zodResolver(schema),
});
resetOptions
useForm({
  values,
  resetOptions: {
    keepDirtyValues: true, // reset時にユーザーが入力した内容が保持される
  },
})

これらを踏まえたFormの実装方法

schemaとFormで管理するデータの型を作成
// schema.ts

export const schema = z.object({
  name: z.string(),
  age: z.coerce.number()
})
export type FormInput = z.infer<typeof schema>
fetchしたデータからdefaultValuesの生成、formデータからrequestデータの変換は関数化する

formで管理していないデータをrequestに含める場合はformInputToXXXのpayloadに渡す。

// utils.ts

export const createDefaultValues = (
  payload?: User
): FormInput => {
  return {
    name: payload?.name ?? '',
    age: payload?.age ?? '',
  }
}

export const formInputToUser = (
  data: FormInput
): User => {
  return {
    name: data.name,
    age: data.age,
  }
}
初期値の取得・formデータの管理・submit後の処理実装を行うhooks
// use-user-form.ts

type Props = {
  userId: number
}
export const useUserForm = ({ userId }: Props) => {
  const { notif } = useNotification()

  const { data, error, loading } = useUser(userId)
  const values = useMemo(
    () => (data != null ? createDefaultValues(data) : undefined),
    [data]
  )

  const form = useForm<FormInput>({
    resolver: zodResolver(schema),
    values,
    defaultValues: createDefaultValues()
  })

  const onSubmit: SubmitHandler<FormInput> = useCallback(
    async (data) => {
      try {
        await updateUser({
          userId,
          user: formInputToUser(data)
        })

        notif({ variant: 'success', title: 'ユーザーを更新しました' })
      } catch {
        notif({ variant: 'error', title: 'ユーザーの更新に失敗しました。' })
      }
    },
    [userId, notif]
  )

  return {
    error,
    loading,
    onSubmit,
    ...form
  }
}
ロジックは基本的にuseXXXFormに丸投げし、controlを引き回したUIを実装
// user-form.tsx

type Props = {
  userId: number
}
export const UserForm: FC<Props> = ({ userId }) => {
  const {
    control,
    formState: { errors, isSubmitting },
    handleSubmit,
    onSubmit
  } = useUserForm({ userId })

  return (
    <Box component="form" onSubmit={handleSubmit(onSubmit)}>
      <Box display="grid" gap={4}>
        <TextField control={control} name="name" />
        <TextField control={control} name="age" />
      </Box>
      <Button type="submit">保存</Button>
    </Box>
  )
}
入力コンポーネントはcontrolを引数に取る<Controller>でwrapしたものを用意しておく
export const TextField = <T extends FieldValues>({
  control,
  name,
  label,
  ...props
}: Props<T>) => {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => {
        return (
          <FormControl fullWidth={true}>
            <_TextField
              label={label}
              {...props}
              {...omit(field, ['disabled'])}
              error={fieldState.error != null}
            />
            {fieldState.error != null && (
              <FormHelperText error={true}>
                {fieldState.error.message}
              </FormHelperText>
            )}
          </FormControl>
        )
      }}
    />
  )
}

個人的な所感ですが、とてもキレイにまとまってると思います。もちろんケースバイケースになるのでその都度考える必要はありますが。

バージョンアップのたびにリファクタが必要になるので大変ですね。