フォーム

最終更新: 2022年02月11日

概要

React 及び Next.js は十分なフォーム管理機能を持たないため、一般的に React Hook Form などのフォームライブラリを使ってフォームの実装を行います。

ライブデモ

インストール

プロジェクトディレクトリで以下のコマンドを使ってインストールします。

ターミナル
npm install react-hook-form

React Hook Form の仕組み

React Hook Form は useForm というカスタムフックを提供します。useForm から得られるメソッドやステートを使ってフォームの設定や値の取得を行います。

  • useForm: フォームを作成する
  • register: 入力フィールドを登録する
  • handleSubmit: 送信を受け取って処理する
  • formState: フォームのエラー状態や入力状態を検知する
  • getValues: その時点のフォームの値を取得
  • watch: 最新のフォーム値を取得

上記を踏まえ、今回は以下の要素を持つユーザーのプロフィール編集フォームを作成することにします。

  • 名前(インプット)
  • 性別(チェックボックス)
  • プロフィール(テキストエリア)
  • 趣味(チェックボックス)
  • 都道府県(セレクタ)
  • タスク(動的に増える入力欄、タスクと日付のインプット欄)
  • 保存ボタン(ボタン)

実装

フォームの作成

まずはフォームを作成します。

import React from "react"; import { useForm } from "react-hook-form"; // フォームデータの型 type User = { name: string; title: string; profile: string; gender: 'male' | 'female'; prefectures: string; hobbies: string[]; tasks: { value: string; limit: string; }[]; }; const UserForm = () => { // フォームの作成 const { register, handleSubmit, formState: { errors, isSubmitting, isSubmitted, isSubmitSuccessful }, control, } = useForm<User>(); } export default UserForm;

フィールドの登録

フォームに含まれるインプットやテキストエリア、チェックボックスなどをフィールドと呼びます。{...register('フィールド名', バリデーション)} のフォーマットでフィールドを登録できます。フィールドをフォームに登録することで値の検知やエラーの判断が可能になります。

<label> 名前 <input type="text" required autoComplete="name" {...register('name', { required: true, maxLength: 200, })} /> </label>
  • required - これによりブラウザ標準の挙動として未入力を禁止するようになります
  • autoComplete="name" - ブラウザが名前に関する自動補完を行うようになります
  • register - name というフィールド名で登録しています。第二引数では必須入力、最大文字数のバリデーションを追加しています。

チェックボックスやラジオボタンの場合それぞれのオプションに同じフィールド名で登録を行います。

<div> <h2>性別</h2> {errors.gender && 'どちらか選択してください'} <label> <input type="radio" required value="male" {...register('gender', { required: true, })} /> 男性 </label> <label> <input type="radio" value="female" required {...register('gender', { required: true, })} /> 女性 </label> </div>

動的なフィールド

ユーザーの操作によって入力欄が増減するようなシーンでは useFieldArray を使います。

  1. useFormで作成たフォームから control を受け取る - STEP 1
  2. controluseFieldArray を使って動的フィールドを作成します - STEP 2
function Test() { // STEP 1 const { control, register } = useForm(); // STEP 2 const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({ control, // useForm から受け取った control を指定 name: "tasks", // フィールド名 }); return ( <div> {fields.map((field, index) => { return ( <div key={field.id}> {/* tasks.${index}.フィールド名` の形式で入力フィールドを登録 */} <input type="text" required {...register(`tasks.${index}.value`, { required: true, })} autoComplete="off" /> <input type="date" {...register(`tasks.${index}.limit`)} autoComplete="off" /> {/* remove(index) により入力欄を削除 */} <button type="button" onClick={() => remove(index)}> 削除 </button> </div> ); })} {/* append({}) により入力欄を追加 */} <button type="button" onClick={() => append({})}> 追加 </button> </div> ); }

この際、 <button> タグに type="button" をつけている点に注意しましょう。これがないと追加や削除ボタンが送信ボタンとして振る舞ってしまい、フォームが送信されてします。<form> 内にボタンを設置する場合送信ボタン以外には必ず type="button" をつけるようにしましょう。

送信の処理

以下のように handleSubmit を使うことで送信の制御ができます。

const onSubmit = async (data: User) => { console.log(data); // 5秒の送信処理 const saveData = new Promise((resolve) => { setTimeout(() => { resolve(true); }, 5000); }); return saveData; }; const onInvalid = (erros: FieldErrors) => { alert('入力項目にエラーがあります'); console.log(erros); }; <form onSubmit={handleSubmit(onSubmit, onInvalid)}> {/* 各フィールド */ </form>

値が正常に入力された場合の処理と、バリデーションに弾かれた状態で送信しようとした場合の処理を引数で切り分けることができます。

handleSubmit(正常な値の場合の処理, 不正な値の場合の処理)

多重送信を防ぐ

送信時の処理(onSubmit)を非同期関数にすることで、データベースにデータを保存するなど送信にまつわる処理が終わったタイミングを検知できるようになります。処理中は isSubmittingtrue になるのでその値を使って送信中のメッセージを表示したり、ボタンを disabled にして押せなくします。

{isSubmitting && <p>送信中...</p>} <button disabled={isSubmitting}>送信</button>

エラー(バリデーション)メッセージの表示

フォームの値が一定のルールに則って正しく入力されているか検証することをバリデーションといいます。バリデーションは前述したフィールドの登録の際に設定していますが、エラーメッセージの表示は formState: { errors } で抽出した errors を使います。この中にはバリデーションに弾かれたフィールドのエラーがオブジェクト形式で含まれています。

どのフィールドがどのタイプ(必須なのか、文字数なのか)のエラーで弾かれているかを知ることでメッセージを切り分けることができます。

<div> <label> 名前 <input type="text" required autoComplete="name" {...register('name', { required: true, maxLength: 60, })} /> </label> {errors.name?.type === 'required' && '必須入力です'} {errors.name?.type === 'maxLength' && '60文字以内にしてください'} </div>

あるいはフィールド登録時にエラーメッセージをセットすることもできます。その場合

required: {
  value: true,
  message: '必須入力です'
}

の形式でバリデーションを設定し

errors.name?.message でエラーメッセージを表示します。

<div> <label> 名前 <input type="text" required autoComplete="name" {...register('name', { required: { value: true, message: '必須入力です' }, maxLength: { value: 60, message: '60文字以内にしてください' }, })} /> </label> {errors.name?.message'} </div>

最適なバリデーション設定とは?

諸説ありますがこちらの記事が参考になります。

フィールド最大文字数
名前60
法人名137
商品名135
住所161
電話番号21
メールアドレス254

オートコンプリートを有効にする

名前やメールアドレス、住所をユーザーが入力するのは大変です。ブラウザが記憶した値を自動で入力できるよう、 autocomplete 属性を設定します。逆に自動補完させたくない場合 autocomplete="off" を指定します。

autocomplete の種類や指定方法は MDN を参照してください。

外部UIライブラリとの連携

入力フォームが Tiptap や Material UI などの UIライブラリによって制御されているケースがあります。その場合コントローラーを使ってReact Hook Formで制御可能なコンポーネントにします。以下はTiptapの例です。

components/editor.tsx
import React, { useEffect } from 'react'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import { useController, UseControllerProps } from 'react-hook-form'; const Editor = (props: UseControllerProps<any>) => { const { field, fieldState, formState } = useController(props); const editor = useEditor({ extensions: [StarterKit], content: field.value, onBlur() { field.onBlur(); }, onUpdate({ editor }) { field.onChange(editor.getText()); }, }); // エラー時にフォーカス useEffect(() => { if (fieldState.invalid) { editor?.commands?.focus(); } }, [fieldState.invalid, formState.submitCount]); return ( <div> <EditorContent editor={editor} /> {fieldState.error?.type === 'required' && '必須入力です'} {fieldState.error?.type === 'maxLength' && '400文字以内にしてください'} </div> ); }; export default Editor;

以下のように使用します。

const { register, handleSubmit, formState: { errors }, control, // 追加 } = useForm<User>(); <Editor control={control} name="profile" rules={{ required: true, maxLength: 400, }} />

Material UI のように構造が本来のHTMLフォームに則っている場合、以下のようにシンプルに記述することもできます。

import React from "react"; import { useForm, Controller, SubmitHandler } from "react-hook-form"; import { TextField, Checkbox } from "@material-ui/core"; interface IFormInputs { TextField: string MyCheckbox: boolean } function App() { const { handleSubmit, control, reset } = useForm<IFormInputs>(); const onSubmit: SubmitHandler<IFormInputs> = data => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <Controller name="MyCheckbox" control={control} defaultValue={false} rules={{ required: true }} render={({ field }) => <Checkbox {...field} />} /> <input type="submit" /> </form> ); }

オリジナルコンポーネントとの連携

以下のフォーマットでカスタムフィールドのコンポーネントを作成します。

components/custom-input.tsx
import React, { InputHTMLAttributes } from 'react'; import { UseFormRegisterReturn } from 'react-hook-form'; type Props = { register: UseFormRegisterReturn; } & InputHTMLAttributes<HTMLInputElement>; const CustomInput = ({ register, ...props }: Props) => { return <input {...register} {...props} />; }; export default CustomInput;

InputHTMLAttributes<HTMLInputElement> の部分はフォーム種類に合わせて変更してください。

  • InputHTMLAttributes<HTMLInputElement> : インプット
  • TextareaHTMLAttributes<HTMLTextAreaElement> : テキストエリア
  • SelectHTMLAttributes<HTMLSelectElement> : セレクタ

使用する際は以下のように使います。

<CustomInput type="text" required autoComplete="organization-title" register={register('title', { required: true, maxLength: 30, })} />