0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ぼっちアドベントカレンダー by bon10Advent Calendar 2024

Day 21

面倒なフォームのHooksをコンポーネント化する

Last updated at Posted at 2024-12-21

絶対にみなさんやってあるであろうことを私もやりました。ということで簡単にまとめます。

react-hook-form + Zod という構成

クライアントサイドでバリデーションを含むフォームを実装するのであれば、これが一番簡単かつ再利用性高いと思います。


import { z } from 'zod';

const passwordFormSchema = z.object({
  email: z.string().email().min(1),
  password: z.string().min(1),
});
type PasswordFormType = z.infer<typeof passwordFormSchema>;

export const useAuthEmailAndPassword = () => {
  const { register, handleSubmit, formState } = useForm<PasswordFormType>({
    mode: 'onTouched',
    resolver: zodResolver(passwordFormSchema),
  });
  const { isDirty, isValid, errors, isSubmitting } = formState;

  const onSubmit = async (data: PasswordFormType) => {
    const result = await verify(data);
    ...
  }

  <form onSubmit={handleSubmit(onSubmit}>
    <input type='text' id='email' name='email' {...register('email')} />
    <br />
    <input type='password' id='password name='password' {...register('password')} /> 
    <div className="h-6">
      {errors.email && typeof errors.email.message === 'string' && (
        <span className="text-red-500 text-xs">{errors.email.message}</span>
      )}
    </div>
    <div>
      {isSubmitting ?
        (
          <button disabled>
            please wait
          </button>
        ) :
        (
          <button disabled={!isDirty || !isValid} type="submit">
            submit
          </button>
        )
       }
    </div>
  </form>

動かしてないので動くかはわかりませんが雰囲気は伝わるかと・・・

recoil + Zod という構成

これはなかなか骨が折れるやつでした。 react-hook-formが実装しているであろうZodのバリデーション周りも自分たちで実装しなければならないので、なんだか車輪の再発明をやっている気分になりました……。

useForm ではなく useRecoilForm というものをつくってみました。
ほぼChatGPTに生成してもらったので、所々なんか不便です(fieldErrorはアプリ側で使ってないので、 const hasErrorsあたりのリファクタは必要かもしれません)

import { useEffect, useState } from 'react';

import { useTranslation } from 'next-i18next';
import { useRecoilState } from 'recoil';
import Zod from 'zod';
import { makeZodI18nMap } from 'zod-i18n-map';

import type { ApprovalStatus } from '@repo/ui/src/types';
import type { RecoilState } from 'recoil';
import type { z } from 'zod';

interface FormData {
  [key: string]: any;
}

interface UseFormParams<T extends FormData, S extends z.ZodObject<any>> {
  mode: 'edit' | 'new'; // edit or new のどちらかを指定。editの場合は初回ロード時に全フィールドに対してバリデーションを実行
  schema: S;
  stateAtom: RecoilState<T>; 
}

export function useRecoilForm<T extends FormData, S extends z.ZodObject<any>>(
  { stateAtom, schema, mode }: UseFormParams<T, S>) {
  const { t } = useTranslation();
  Zod.setErrorMap(makeZodI18nMap({ t }));
  const [formState, setFormState] = useRecoilState(stateAtom);
  const [initialLoad, setInitialLoad] = useState(true);
  const [focusedField, setFocusedField] = useState<keyof T['data'] | null>(null);

  const validateField = (fieldName: keyof T['data'], fieldValue: any) => {
    if (fieldValue === '' || fieldValue === null) fieldValue = undefined;
    const result = schema.pick({ [fieldName]: true } as { [x: string]: true | undefined }).safeParse({ [fieldName]: fieldValue });
    if (!result.success) {
      const message = result.error.issues.map(issue => issue.message).join(', ');
      return { message, success: false };
    }
    return { success: true };
  };

  const validateForm = (data: T['data']) => {
    const validationResults = Object.entries(data).map(([fieldName, fieldValue]) => {
      const result = validateField(fieldName as keyof T['data'], fieldValue);
      return { fieldName, ...result };
    });

    const hasErrors = validationResults.some(result => !result.success);

    const newMessages = validationResults.reduce((acc, { fieldName, success, message }) => {
      if (!success) {
        acc[fieldName] = message;
      } else {
        acc[fieldName] = undefined;
      }
      return acc;
    }, {} as { [key: string]: string | undefined });

    setFormState(prevState => ({
      ...prevState,
      approval: hasErrors ? 'disapprove' as ApprovalStatus : 'approve' as ApprovalStatus,
      fieldErrors: { ...prevState.fieldErrors, ...newMessages },
      messages: { ...prevState.messages, ...newMessages },
    }));
    return !(hasErrors);
  };

  const handleChange: React.ChangeEventHandler<any> = (e) => {
    const target = e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
    const { name, value, type } = target;
    let finalValue: any;
    if (type === 'checkbox') {
      finalValue = (target as HTMLInputElement).checked;
    } else {
      finalValue = value;
    }
    if (finalValue === undefined) {
      return;
    }
    setFormState(prevState => ({
      ...prevState,
      data: { ...prevState.data, [name]: finalValue },
    }));
    validateForm({ ...formState.data, [name]: finalValue });
  };

  const handleDateChange = (name: keyof T['data'], date: Date | undefined) => {
    setFormState(prevState => ({
      ...prevState,
      data: { ...prevState.data, [name]: date },
    }));
    validateForm({ ...formState.data, [name]: date });
  };

  const handleSelect = (name: keyof T['data'], value: any) => {
    setFormState(prevState => ({
      ...prevState,
      data: { ...prevState.data, [name]: value },
    }));
    validateForm({ ...formState.data, [name]: value });
  };

  const handleBlur = (fieldName: keyof T['data']) => {
    setFocusedField(null);
    const fieldValue = formState.data[fieldName];
    const result = validateField(fieldName, fieldValue || undefined);

    if (!result.success) {
      const message = result.message;
      setFormState(prevState => ({
        ...prevState,
        approval: 'disapprove' as ApprovalStatus,
        fieldErrors: { ...prevState.fieldErrors, [fieldName]: message },
        messages: { ...prevState.messages, [fieldName]: message }, // エラーメッセージを保存
      }));
    } else {
      setFormState(prevState => ({
        ...prevState,
        approval: 'approve' as ApprovalStatus,
        fieldErrors: { ...prevState.fieldErrors, [fieldName]: undefined },
        messages: { ...prevState.messages, [fieldName]: undefined }, // エラーメッセージをクリア
      }));
    }
  };

  const handleFocus = (fieldName: keyof T['data']) => {
    setFocusedField(fieldName);
  };

  // フォーム全体のバリデーション結果をチェックする関数
  const checkFormApprovalStatus = () => {
    const hasErrors = Object.values(formState.fieldErrors ?? {}).some(error => error !== undefined);
    setFormState(prevState => ({
      ...prevState,
      approval: hasErrors ? 'disapprove' as ApprovalStatus : 'approve' as ApprovalStatus,
    }));
  };

  // 初期ロード時にはバリデーションを行わない
  useEffect(() => {
    if (initialLoad) {
      setInitialLoad(false);
    } else if (mode === 'edit') {
      validateForm(formState.data);
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [formState.data]);

  // 各変更の後にフォーム全体の承認ステータスを確認
  useEffect(() => {
    checkFormApprovalStatus();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [formState.fieldErrors]);

  return { focusedField, formState, handleBlur, handleChange, handleDateChange, handleFocus, handleSelect, setFormState, validateForm };
}

approval というのでエラーがあるかないかを判定できるようにしています。
また、エラーのメッセージは各fieldNameに格納されます。fieldNameはRecoilのdataで定義したキー名になります。
例えばこんな感じでRecoilを定義します。

export const emailPasswordSchema = z.object({
  email: z.string().email(),
  password: z.string(),
});

type EmailPasswordType = z.infer<typeof emailPasswordSchema>;

type EmailPasswordMessage = {
  email?: string;
  password?: string;
}>;

export interface EmailPasswordStates {
  approval: 'disapprove' | 'approve';
  data: EmailPasswordType;
  messages: EmailPasswordMessage;
}

export const emailPasswordStates = atom<EmailPasswordStates>({
  default: {
    approval: 'disapprove',
    data: defaultAddLongListToDoData,
    messages: defaultAddLongListToDoMessages,
  },
  key: `${KEY_EMAIL_PASSWORD}`,
});

ZodだけでなくRecoilの定義も入ってくるので若干分かりづらいですね。
私はこの時点でフォームを書くたびにこの数の定義を書かなければならないのが苦痛で辞めたくなりました(笑)

使い方はこんな感じです。

const { formState, handleChange, handleBlur, handleDateChange } = useRecoilForm({
    mode: 'new',
    schema: emailPasswordSchema,
    stateAtom: emailPasswordStates,
  });
  <form onSubmit(xxx) >
    <input type='text' name='email' value={formState.data.email} />
    <input type='password' name='password' value={formState.data.password} />
  ()
  

まとめ

個人的にはエコシステムを最大限に利用することをオススメします!
ただし、作り込むべきところ(それが自社プロダクトの最大の技術的価値になるものだったり、特許技術だったり)は自分たちで開発しましょう。
今回のようなフォーム周りは、正直苦労して作り込むとしてもエラーメッセージをどうハンドリングして表示する、あるいは階層を持つフォームを作る(選択肢を選ぶと配列型のデータ登録や編集が必要となる)などくらいかなと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?