Closed5

React Hook Formを使ってsubmit on change

さんたむさんたむ

React Hook Form(以下RHF)はフォームの値を管理・バリデーションを支援してくれるライブラリだが、基本的にボタンを押してフォームの値をsubmitするという形式で動いているので、submit on changeをするためには多少の工夫が必要だった。その過程で生じた工夫と疑問点をまとめる

さんたむさんたむ

基本的にRHFのリポジトリにあるこちらのディスカッションを参考にしながら、以下のように日付を入力すると日付順でソートが行われるフォームを実装した。この時、日付の入力部分がsubmit on changeになっている。

さんたむさんたむ
  • Container.tsx
import { FC, useCallback, useState } from 'react';
import { VStack, Text, HStack } from '@chakra-ui/react';
import type { DateID } from 'schemas/time';
import { Day } from 'schemas/time';
import InputForm from './InputForm';

const compare = (a: Day, b: Day): number => {
  if (a.toISO8601() === b.toISO8601()) return 0;
  else if (a.toISO8601() > b.toISO8601()) return 1;
  else return -1;
};

const Container: FC = () => {
  const [days, setDays] = useState<Map<DateID, Day>>(
    new Map([
      [0, new Day(2023, 1, 20)],
      [1, new Day(2023, 1, 21)],
      [2, new Day(2023, 1, 22)],
    ])
  );

  console.log('rendered!');

  const setDay = useCallback((id: DateID, day: Day): void => {
    setDays((days) => {
      const entries = [...days.set(id, day).entries()].sort(([_, a], [__, b]) =>
        compare(a, b)
      );

      return new Map(entries);
    });
  }, []);

  return (
    <VStack>
      {[...days.entries()].map(([id, day], i) => (
        <HStack key={i}>
          <Text>{i + 1}:</Text>
          <InputForm
            key={id}
            dateID={id}
            defaultDate={day.toISO8601()}
            setDay={setDay}
          />
        </HStack>
      ))}
    </VStack>
  );
};

export default Container;
  • InputForm.tsx
import { FC, useEffect } from 'react';
import { FormControl, FormErrorMessage, Input } from '@chakra-ui/react';
import { yupResolver } from '@hookform/resolvers/yup';
import { useForm } from 'react-hook-form';
import { Day } from 'schemas/time';
import type { ISO8601, DateID } from 'schemas/time';
import { dateFormSchema, DateFormSchema } from 'schemas/validation';

const InputForm: FC<{
  dateID: DateID;
  defaultDate: ISO8601 | undefined;
  setDay: (id: DateID, date: Day) => void;
}> = ({ dateID, defaultDate, setDay }) => {
  const {
    register,
    watch,
    formState: { errors, isValidating, isValid },
  } = useForm<DateFormSchema>({
    mode: 'onChange',
    defaultValues: { date: defaultDate },
    resolver: yupResolver(dateFormSchema),
  });

  // 入力値に変化があるたびにsubmitする
  const date = watch('date');
  useEffect(() => {
    if (isValid && !isValidating) {
      console.log('date is changed!: ', date);
      setDay(dateID, Day.fromISO8601(date as ISO8601));
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [dateID, date, isValid, isValidating]);

  return (
    <form>
      <FormControl margin={5} isInvalid={errors.date !== undefined}>
        <Input w="157px" type="date" isRequired {...register('date')} />
        <FormErrorMessage>{errors.date?.message}</FormErrorMessage>
      </FormControl>
    </form>
  );
};

export default InputForm;
さんたむさんたむ

工夫した点

  • InputForm.tsxのuseEffectの依存配列に日付を更新する関数setDay()を含めないようにした
    • setDay()はレンダリングごとにアドレスが変わるため、useEffect()の依存配列に含めてしまうとContainerのstateの更新→useEffect実行→stateの更新・・・と無限ループが発生する
  • useEffectの依存配列にsetDay()を含めなくて良いようにするために、useCallbacksetDay()を包んだ
    • ドキュメントに書かれているように、再レンダリング間でsetStateは同一性が保持されるため、useCallbackの依存配列にはsetDays()を入れる必要がなくなり、その結果setDay()についても再レンダリング間で同一性が保たれるようになる
さんたむさんたむ

疑問点

  • Container.tsxでsetDay()関数をuseCallbackに包まず、さらにInputFormでsetDay()関数をuseEffectの依存配列に含めないという対応をしてもなぜかうまく行く
    • setDay()によってContainerのstateを更新した場合、再レンダリングによってsetDay()のアドレスが更新されるため、依存配列に含めない場合は古いsetDay()が参照されてstateの更新がうまくいかないはずでは?
このスクラップは5ヶ月前にクローズされました