見出し画像

React Hook Form完全攻略!TypeScriptでつくる配列構造が混ざったReact Hook Formのフォーム

まずはじめに

リッチなユーザーインターフェースを実装する場合、ブラウザ上で絵を描いたり、ブロックをドラッグアンドドロップしてLPを作成したりなどがあります。
通常のWebサイトにおいて、難易度が高いUIというと、フォームの制御が挙げられるように思います。
そして、一般的なサンプルではシンプルな構造のフォームがよく使われます。

● 氏名
● 住所
● 郵便番号
● 性別

簡単な項目に関しては、よくReact Hook Formを使用している例が見られます。
この場合、<input />, <textarea />, <select />など、Component化されていないHTML要素がベースとなっています。

1.React Hook Formを業務で使用する

お問い合わせのような簡単なフォームではReact Hook Formを使用することで比較的簡単に作成できます。
しかし、実際の業務ではより複雑なフォームが必要となることが多いです。

例えば、<Input />, <TextArea />などのフォーム要素ごとにコンポーネント化され、ネストしたフォームが動的に増える場合もあります。
さらに、特定の条件に応じてフォームの初期値を設定し、ユーザーが変更できるようにする必要もあります。

このような複雑なフォームを、単純なステートで管理することは非常に困難です。
そこで、React Hook Formを使用して実務レベルの複雑なフォームを作成してみることにします。

2.今回作りたいもの

管理画面などで使用される複雑なフォームを作成したいと思います。
今回作成するフォームの要件となります。

● 商品名、発注数、納品数を一つの複数行フォームとする。
● ページをロードすると各フォームには初期値が入っている。
● 発注に対して納品される数が入力できるようになっている。
● 発注数は初期値を変更できない。
● メモを入力することができる。
● 送料を入力することができる。
● 追跡番号を入力することができる。
● ひとつでもエラーがある場合、Submitボタンは非アクティブになる。

バリデーションのルールは以下の通りです。

●メモ欄を除く項目は、数値以外を入力するとエラー。
●追跡番号は12桁を超えるとエラー。
●初期値の発注数を超えて納品数を入力するとエラー。
●空の場合はエラー。

以下はモデルの構造です。
モデル構造には通常のフォーム構造に加えて、配列構造のフォームが含まれています。

export type IOrder = {
  deliveryCost: number | null// 送料
  trackingNumber: string | null// 追跡番号
  notes: string | null//メモ
  orderItem: IOrderDetail[]; // 発注商品
};

export type IOrderDetail = {
  id: number;
  name: string// 商品名
  orderCount: number// 発注数
  orderRealCount: number// 納品数
};

3.コンポーネント化していく

各パーツをコンポーネント化していきます。
また、スタイリングにはTailWindを使用します。

// table.tsx

//----------------------------------------
// props
//----------------------------------------
type Props = {
  children: React.ReactNode;
};

//----------------------------------------
// component
//----------------------------------------
const Index = ({ children }: Props) => {
  return <table className='w-full'>{children}</table>;
};

export default Index;
// tableHeading.tsx

//----------------------------------------
// props
//----------------------------------------
type Props = {
  text: string;
  index: number;
};

//----------------------------------------
// component
//----------------------------------------
const Index = (props: Props) => {
  return (
    <>
      <th
        className={`
          px-2 first:pl-0 last:pr-0 text-xs text-left text-gray-600 w-3/12
        `}
      >
        {props.text}
      </th>
    </>
  );
};

export default Index;
// tableBody.tsx

import { ReactNode } from 'react';

//----------------------------------------
// props
//----------------------------------------
type Props = {
  children: ReactNode;
};

//----------------------------------------
// component
//----------------------------------------
const Index = ({ children }: Props) => {
  return <tbody className={'before:table-row before:h-4 before:content-[""]'}>{children}</tbody>;
};

export default Index;
// tableRow.tsx

import { ReactNode } from 'react';

//----------------------------------------
// props
//----------------------------------------
type Props = {
  isScroll?: boolean;
  children: ReactNode;
};

//----------------------------------------
// component
//----------------------------------------
const Index = ({ children, isScroll }: Props) => {
  return <tr className={'align-top table-row w-12'}>{children}</tr>;
};

export default Index;
// tableData.tsx

import { ReactNode } from 'react';

//----------------------------------------
// props
//----------------------------------------
type Props = {
  width: number | string;
  children: ReactNode;
};

//----------------------------------------
// component
//----------------------------------------
const Index = ({ children, width }: Props) => {
  return <td className={`px-1 text-sm text-gray-600 w-3/12 first:pl-0 last:pr-0`}>{children}</td>;
};

export default Index;
// input.tsx

import { RegisterOptions, UseFormRegister } from 'react-hook-form';
import { IOrder } from 'models/order';
import { IRegister } from 'models/register';

//----------------------------------------
// props
//----------------------------------------
type Props = {
  name: IRegister;
  defaultValue: string | number | null;
  register: UseFormRegister<{ order: IOrder }>;
  registers: RegisterOptions;
  isDisabled?: boolean;
};

//----------------------------------------
// component
//----------------------------------------
const Index = ({ name, defaultValue, register, registers, isDisabled }: Props) => {
  return (
    <input
      type={'text'}
      defaultValue={String(defaultValue)}
      disabled={isDisabled}
      {...register(name, registers)}
      className={`
        border-gray-200 focus:border-gray-500 focus:outline-none appearance-none
        block py-3 px-4 w-full leading-tight text-gray-700 focus:bg-white rounded border
      `}
    />
  );
};

export default Index;
// textarea.tsx

import { RegisterOptions, UseFormRegister } from 'react-hook-form';
import { IOrder } from 'models/order';
import { IRegister } from 'models/register';

//----------------------------------------
// props
//----------------------------------------
type Props = {
  name: IRegister;
  order: IOrder;
  register: UseFormRegister<{ order: IOrder }>;
  registers: RegisterOptions;
  isDisabled?: boolean;
  placeholder: string;
};

//----------------------------------------
// component
//----------------------------------------
const Index = ({ name, order, register, registers, isDisabled, placeholder }: Props) => {
  return (
    <textarea
      rows={8}
      disabled={isDisabled}
      placeholder={placeholder}
      defaultValue={order.notes ? order.notes : ''}
      {...register(name, registers)}
      className='
          border-gray-200 focus:border-gray-500 focus:outline-none
          appearance-none
          block py-3 px-4 w-full leading-tight text-gray-700
        focus:bg-white rounded border
      '
    />
  );
};

export default Index;

フォームに必要なコンポーネントが作成されました。
それぞれのテーブルのヘッディングや入力欄を作ることができました。

4.React Hook Formを組み込む

では、これらのコンポーネントを組み込んでいきましょう。

1.tableHeading コンポーネントは、heading 配列をループさせて表示する。
2.orderItem 配列の初期値をフェッチする。
3.フェッチしたデータを元に、ループ処理を行いながら初期値を持つフォームを表示する。

以下のようにReact Hook Formを初期化します。

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

const { control, register, handleSubmit, reset, formState: { errors, isValid } } = useForm<{ order: IOrder }>({
    mode'onChange',
    reValidateMode'onChange'
});

const { fields } = useFieldArray({ control, name'order.orderItem' });

useFormuseFieldArrayを初期化します。
useFieldArrayは公式ドキュメントに以下のような説明があります。
このuseFieldArrayは以下の通りの説明が公式に存在します。

フィールド配列(動的な複数の input)を操作するためのカスタムフック。 制御されたフィールド配列と非制御フィールド配列を比較できます。

今回はuseFieldArrayを使用するのは、order内のorderItemが配列構造を持っているためです。
この配列内のフォームを操作するためにuseFieldArrayを利用します。

なお、useFieldArrayにはフォームを動的に追加・削除するためのメソッドも存在しますが、今回は使用しません。
しかし、ユーザーが追加ボタンや削除ボタンを押すことでフォームを動的に追加・削除する場合は、useFieldArrayを利用することで簡単にフォームの構築が可能です。
このような動的なフォームの追加・削除がメインの場合には特に便利です。

// 1.tableHeading コンポーネントは、heading 配列をループさせて表示する。

<Table>
  <TableHead>
    <TableRow isScroll>
      {tableHeaidngs.map((heading: string, index: number) => {
        return <heading key={index} index={index} text={heading} />;
      })}
    </Row>
</Head>
// 2.orderItem 配列の初期値をフェッチする。

const [order, setOrder] = useRecoilState(orderAtom); // 状態管理にRecoilを使用

const fetch = async () => {
   // orderItem配列である初期値をFetchする処理を書く
   const datas = // なにかAPIを叩く処理
   return datas;
}

useEffect(() => {
  const initOrderItem = async () => {
    const fetchDatas = await fetch();
    setOrder(fetchDatas);
  }

  initOrderItem();

  reset({ order: order });
}, [reset]);

1.はそのままです。
2.初期値を useEffect 内で取得し、useForm の初期化時に取り出した reset 関数に渡しています。

5.非同期で初期値を設定する

フォームでは初期値を設定することが非常に多いです。
初期値を設定するには defaultValue を使用する必要がありますが、APIのレスポンスや状態管理ライブラリから非同期に渡される値を使用したいことがほとんどだと思います。

しかし、非同期に渡されるデータを useStateuseRecoil などで設定した defaultValue に直接セットしても正しく動作しません。
なぜなら、初期化時の空の状態で設定された初期値が反映されてしまうからです。

このような場合に対処する方法が reset 関数です。

reset 関数を引数なしで実行するとフォームが空にリセットされますが、Fetch後に設定された初期データオブジェクトを渡すとその値でフォームがリセットされます。
これにより、 defaultValue に適切な初期値が設定されるようになります。

次に、 orderItem に初期値を設定しながらループさせ、メモ、送料、追跡番号も入力できるようにします。

// フェッチしたデータを元に、ループ処理を行いながら初期値を持つフォームを表示する。

<Table>
{fields.map((field, index) => (
  <Body key={field.id}>
    <Row>
      <Data>
        {index + 1}.{order.orderItem[index].name}
      </Data>
      <Data>
        <Input
          name={`order.orderItem.${index}.orderCount`}
          register={register}
          registers={{
            required: {valuetrue, message: '必ず入力してください。'},
            pattern: {value: /^[0-9]+$/i, message: '半角数字で入力してください。'},
            max: {value: confirmHooks.supply.T_SUPPLY_DETAILS[index].order_count, message: '発注数より多く納品することはできません。'}
          }}
          defaultValue={order.orderItem[index].orderCount}
          isDisabled
        />
        <ErrorMessage>{errors?.['order']?.['orderItem']?.[index]?.['orderCount']?.['message']}</ErrorMessage>
      </Data>
      <Data>
        <Input
          name={`order.orderItem.${index}.orderRealCount`}
          register={register}
          registers={registers.orderRealCount}
          defaultValue={order.orderItem[index].orderRealCount}
        />
        <ErrorMessage>{errors?.['order']?.['orderItem']?.[index]?.['orderRealCount']?.['message']}</ErrorMessage>
      </Data>
    </Row>
  </Body>
))}
</Table>

// メモ入力
<div className={'mt-10'}>
  <Lead size={'xs'} text={'メッセージを入力'} bold />
  <Textarea
    name={`order.notes`}
    register={register}
    registers={registers.notes}
    placeholder={'何かございましたらこちらにメッセージの入力をお願い致します。'}
  />
</div>

// メモ入力
<div>
  <Lead size={'xs'} text={'メッセージを入力'} bold />
  <Textarea
    name={`order.notes`}
    register={register}
    registers={registers.notes}
    placeholder={'何かございましたらこちらにメッセージの入力をお願い致します。'}
  />
</div>

// 送料を入力
<div>
  <Lead size={'xs'} text={'送料(税抜)'} bold />
  <Input
    name={`order.deliveryCost`}
    register={register}
    registers={registers.deliveryCost}
    defaultValue={order.deliveryCost}
  />
  <ErrorMessage>{errors?.['order']?.['deliveryCost']?.['message']}</ErrorMessage>
</div>

// 追跡番号を入力
<div>
  <Lead size={'xs'} text={'追跡番号'} bold />
  <Input
    name={`order.trackingNumber`}
    register={register}
    registers={registers.trackingNumber}
    defaultValue={order.trackingNumber}
  />
  <ErrorMessage>{errors?.['order']?.['trackingNumber']?.['message']}</ErrorMessage>
</div>

ここでのポイントは name です。

name={`order.orderItem.${index}.orderCount`}

ループを回す際にインデックスを使用して、name 属性を設定しています。これにより、配列のフォームを管理することができます。

const { register } = useForm<{ order: IOrder }>({
  mode: 'onChange',
  reValidateMode: 'onChange'
});

<Input
  name={`order.orderItem.${index}.orderRealCount`}
  register={register}
  registers={{
    required: {valuetrue, message: '必ず入力してください。'},
    pattern: {value: /^[0-9]+$/i, message: '半角数字で入力してください。'},
    max: {value: confirmHooks.supply.T_SUPPLY_DETAILS[index].order_count, message: '発注数より多く納品することはできません。'}
  }}
  defaultValue={order.orderItem[index].orderRealCount}
/>

useForm で取り出した registerInput コンポーネントにそのまま渡し、registers には3つの registers を渡しています。

registers={{
  required: {valuetrue, message: '必ず入力してください。'},
  pattern: {value: /^[0-9]+$/i, message: '半角数字で入力してください。'},
  max: {value: confirmHooks.supply.T_SUPPLY_DETAILS[index].order_count, message: '発注数より多く納品することはできません。'}
}}

バリデーションの条件をオブジェクトで渡しています。
これは、最新のデータからしか発注数を取得できないため、後から条件を追加できるようにするためです。
これにより、初期値をFetchしてフォーム行に設定し、ループで表示することができました。

// メモ入力

<div>
  <Lead size={'xs'} text={'メッセージを入力'} bold />
  <Textarea
    name={`order.notes`}
    register={register}
    registers={registers.notes}
    placeholder={'何かございましたらこちらにメッセージの入力をお願い致します。'}
  />
</div>

// 送料を入力
<div>
  <Lead size={'xs'} text={'送料(税抜)'} bold />
  <Input
    name={`order.deliveryCost`}
    register={register}
    registers={registers.deliveryCost}
    defaultValue={order.deliveryCost}
  />
  <ErrorMessage>{errors?.['order']?.['deliveryCost']?.['message']}</ErrorMessage>
</div>

// 追跡番号を入力
<div>
  <Lead size={'xs'} text={'追跡番号'} bold />
  <Input
    name={`order.trackingNumber`}
    register={register}
    registers={registers.trackingNumber}
    defaultValue={order.trackingNumber}
  />
  <ErrorMessage>{errors?.['order']?.['trackingNumber']?.['message']}</ErrorMessage>
</div>

メモ、送料、追跡番号を入力できるフォームも追加されましたが、これらは配列ではないため、indexの添字を使用せずに通常のフォームとして設定されています。
もう一度、Inputコンポーネントを確認してみましょう。

// input.tsx

import { RegisterOptions, UseFormRegister } from 'react-hook-form';
import { IOrder } from 'models/order';
import { IRegister } from 'models/register';

//----------------------------------------
// props
//----------------------------------------
type Props = {
  name: IRegister;
  defaultValue: string | number | null;
  register: UseFormRegister<{ order: IOrder }>;
  registers: RegisterOptions;
  isDisabled?: boolean;
};

//----------------------------------------
// component
//----------------------------------------
const Index = ({ name, defaultValue, register, registers, isDisabled }: Props) => {
  return (
    <input
      type={'text'}
      defaultValue={String(defaultValue)}
      disabled={isDisabled}
      {...register(name, registers)}
      className={`
        border-gray-200 focus:border-gray-500 focus:outline-none appearance-none
        block py-3 px-4 w-full leading-tight text-gray-700 focus:bg-white rounded border
      `}
    />
  );
};

export default Index;

各フォームには、先程のPropsを介して必要な値が渡され、適切に設定されています。
フォームのSubmitボタンにはhandleSubmit関数がイベントとして割り当てられており、バリデーションが設定されている場合はSubmitが無効になるようになっています。
また、orderステートの変化を確認するために、console.logを使用して各フォームの値の変更をトラッキングしてください。
バリデーションがしっかりと実装されているはずです。
各フィールドの値を変更すると、配列内の正しいインデックスの値が適切に変更されることも確認してください。

order: {
  deliveryCost:'800'
  notes: 'これはメモです'
  orderItem: (2) [
    {
      name'えんぴつ',
      orderCount'20',
      orderRealCount'20',
    },
    {
      name'じょうぎ',
      orderCount'16',
      orderRealCount'12',
    },
  ]
  trackingNumber: '123456789012'
}

正しく実装されていれば、以下のようなデータが返されるはずです。

6.React Hook Formは便利だが難しい

● 各パーツをコンポーネント化する場合
● 配列と通常のフォームが混ざっている場合
● 初期値の設定が必要な場合
● バリデーションの条件をFetch後に追加する場合

などの難しい点も多かったですが、この記事を参考にすることでそれらの問題に対処できると思います。

最後に、React Hook Formの型補完が結構弱いという点ですが、型のドキュメントを用意していますので、コンポーネント化する際にはPropsの型指定を行うことをおすすめします。こちらを参考にしてください。


この記事が気に入ったらサポートをしてみませんか?