🦊

入力要素が少し違うフォームをReactで量産するときに

2024/06/21に公開

はじめに

react-hook-form はフォームを作る際に便利なライブラリですが、フォームが複数のコンポーネントに分割されていると複雑になります。
特に送信周りを担う基底コンポーネントとフォームの表示を担うフォームコンポーネントが別であるような場合はcontrolの受け渡しがprops経由ではできなくなります。
このような事例は多くはないのですが、ここではある記事投稿サイトを想定し、「記事のカテゴリを選ぶとそれに応じたフォームが表示される」ような状況を想像してみましょう。

全体設計

まず各カテゴリに対応するフォームをどんな形で作りたいかを想像しながら、最終系を設計してみましょう。
このような形になるでしょうか。

export const EngineeringForm = () => {
  return (
    <Base>
      <TitleForm placeholder='UIの情報構造に合わせたReact Componentを作る、UI Structureパターン' />
      <SelectorForm values={['Python', 'C', 'C++', 'Java', 'C#', 'JavaScript']} />
      <SelectorForm values={['フロントエンド', 'バックエンド', 'インフラ']} />
      <BodyForm />
      <GitHubForm />
    </Base>
  )
}
export const DesignForm = () => {
  return (
    <Base>
      <TitleForm placeholder='UI再考 - スイッチ編' />
      <SelectorForm values={['Web/App', 'ゲーム']} />
      <BodyForm />
      <PortfolioForm />
    </Base>
  )
}

エンジニア向けの記事ではプログラミング言語やエンジニアリング領域を選択してもらいたい。
デザイナ向けの記事でも同じように領域を選択してもらいたいけど、その内容はエンジニアのそれとは異なりますよね。
また記事から筆者の実績を知れるように、それぞれGitHubのアカウントやポートフォリオサイトのリンクを紐づけて欲しい。
こういった状況では、 Base コンポーネントに送信ロジックを寄せつつ、useContextでcontrolを各フォームに渡すやり方が有効です。

BaseとFormの設計

まず Base ではuseFormを使って値を管理できるようにしつつ、送信ロジックも備えるように実装します。
またuseFormから作った control をcontextで持つようにして、子Formでそれを使えるようにします。

Base

import { Control, useForm, useFieldArray } from 'react-hook-form';

type FormElement = {
  elementId: string;
  elementType: 'selector' | 'github' | 'portfolio';
  value: string;
}

type Form = {
  title: ''
  formElements: [],
}

export const FormContext = createContext<{
  control: Control<Form> | null;
  append: (formElement: FormElement) => void;
  getFormElementIndex: (elementId: string) => number;
}>({
  control: null,
  setDefaultFormElement: () => {}
  getFormElementIndex: () => -1,
})

export const Base = ({ children }): PropsWithChildren => {
  const {
    control,
    handleSubmit,
  } = useForm<{
      title: string,
      formElements: FormElement[]
    }>({
    defaultValues: Form,
    mode: 'onChange',
  });

  const { append } = useFieldArray({
    control,
    name: 'formElements',
  });

  const getFormElementIndex = useCallback(
    (elementId: string) => {
      return watch('formElements').findIndex((attr) => attr.elementId === elementId);
    },
    [],
  );

  const onSubmit = (data) => console.log(data)

  return (
    <FormContext.Provider value={{ control, append, getFormElementIndex }}>
      <form onSubmit={handleSubmit(onSubmit)}>
        {children}
        <input type="submit" />
      </form>
    </FormContext.Provider >
  )
}

ここで control と一緒に appendgetFormElementIndex も渡すようにしています。
append は子フォームが呼び出されたときに一回だけ呼び出される初期化関数です。
また子フォームが control から値を参照するには値の位置を知る必要があるので、 getFormElementIndex を使ってその位置を取得します。

SelectorForm

type Props = {
  elementId: string;
  values: string[];
};

export const SelectorForm = ({ elementId, values }: Props) => {
  const { control, append, getFormElementIndex } = useContext(FormContext);

  useEffect(() => {
    append({
      elementId,
      elementType: 'selector',
      value: '',
    });
  }, []);

  const elementIndex = getFormElementIndex(elementId);
  if (elementIndex === -1) {
    return null;
  }

  return (
    <Controller
      control={control}
      name={`formElements.${elementIndex}`}
      render={({ field, formState }) => (
        ...
      )}
    />
  )
}

こうすることで、入力形式が異なるけど同じ送信ロジックを持つフォームを量産することができるようになりました。

Discussion