LoginSignup
1
1

More than 1 year has passed since last update.

Wizard形式のフォームをつくる with react-hook-form

Posted at

はじめに

この記事ではReact + TypescriptでWizard形式(マルチステップ)のフォームを作るレシピをまとめています。

フォームの制御にはreact-hook-formを使用します。
react-hook-formについては別の記事でまとめているのでよかったら併せて読んでください。

React + Typescript 〜react-hook-formを使って超簡単にフォームvalidationを実装する〜 - Qiita

参考にしたサイト

https://www.google.com/search?q=react+multi+step+form&oq=react+multi+step+form&aqs=chrome..69i57j0i19i512l9.11034j0j7&sourceid=chrome&ie=UTF-8#fpstate=ive&vld=cid:7d149295,vid:uDCBSnWkuH0

こちらの動画が大変参考になりました。
解説はすべて英語ですが、とても分かりやすいので英語が聞き取れなくても理解できると思います。

完成品

1つのURLで複数の入力ステップを踏むフォームを実装します。

e22c857a5aef71a4f307d91eb6113c44.gif

今回はユーザー情報の登録を想定して下記のようなデータ構造で実装を進めます。

type User = {
  nickname: string
  firstName: string
  lastName: string
  email: string
  phoneNumber: string
  password: string
  passwordConfirmation: string
}

尚、この記事はWizard形式のフォームの実装にフォーカスしているため、react-hook-formのvalidationはすべて入力必須に留めます。

実装の準備

まずは一画面に全てのinputを表示していきます。
各inputとエラーメッセージは重複するためコンポーネントに切り出しています。

Form.tsx
import { FC } from "react"
import { SubmitHandler, useForm } from "react-hook-form"

import { InputName } from "../types/inputName"
import { User } from "../types/user"
import { ErrorMessage } from "./ErrorMessage"
import { Input } from "./Input"

const formItems: { name: InputName, label: string }[] = [
  { name: 'nickname', label: 'ニックネーム' },
  { name: 'firstName', label: '名前' },
  { name: 'lastName', label: '名字' },
  { name: 'email', label: 'メールアドレス' },
  { name: 'phoneNumber', label: '電話番号' },
  { name: 'password', label: 'パスワード' },
  { name: 'passwordConfirmation', label: '確認用' },
]

const Form: FC = () => {
  const {
    handleSubmit,
    register,
    formState: { errors }
  } = useForm<User>()

  const onSubmit: SubmitHandler<User> = (data) => console.log(data)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {formItems.map(({ name, label }) => (
        <div key={label}>
          <Input
            name={name}
            register={register}
            label={label}
          />
          {errors[name] && <ErrorMessage />}
        </div>
      ))}
      <input type="submit" value="提出する" />
    </form>
  )
}

export default Form
inputName.ts
export type InputName =
  | 'nickname'
  | 'lastName'
  | 'firstName'
  | 'email'
  | 'phoneNumber'
  | 'password'
  | 'passwordConfirmation'
Input.tsx
import { FC } from "react"
import { UseFormRegister } from "react-hook-form"

import { InputName } from "../types/inputName"
import { User } from "../types/user"

type Props = {
  register: UseFormRegister<User>
  label: string
  name: InputName
}

export const Input: FC<Props> = ({ register, label, name }) => (
  <label htmlFor={name}>
    {label}
    <input
      type="text"
      id={name}
      { ...register(name, { required: true }) }
    />
  </label>
)
ErrorMessage.tsx
export const ErrorMessage = () => <span>入力必須です</span>

inputの共通化について

input要素はいずれも下記の属性を持っています。

  • register
  • registerの第一引数に渡すname
  • label
  • id
  • for

これをpropsとして抽出することで共通化ができます。

import { UseFormRegister } from "react-hook-form"
import { InputName } from "../types/inputName"

type Props = {
  register: UseFormRegister<User>
  label: string
  name: InputName
}

今回のケースではname、id、forは同じ値を取るためnameとしてまとめています。

registerの型はこちらから確認ができますが、UseFormRegister<T>としてTにはuseForm実行時の型引数と同じ型を渡します。今回の場合はUserです。

registerの第一引数に渡せるnameはこの型Userに含まれているプロパティに限られるため、InputNameという型を定義してnameの型に指定します。

フォーム内の入力欄を3分割にする

次にformタグ内を3つのコンポーネントに分割しましょう。
今回はBaseInputsという共通コンポーネントを作ります。

まずは共通部分であるregister、errorsをコンポーネントのpropsとして定義します。
errorsの型はエディタ(筆者はVSCode)上でerrosにマウスホバーすると型を確認できます。

Partial<FieldErrorsImpl<{
    nickname: string
    firstName: string
    lastName: string
    email: string
    phoneNumber: string
    password: string
    passwordConfirmation: string
}>>

上記のように表示されたので

Partial<FieldErrorsImpl<User>>

とすれば良さそうです。

registerの型は先に記載した通りです。
また、BaseInputsの中身の部分、つまりそれぞれが持つinput要素に与えるname、labelもpropsとして抽出してあげます。

最終的な実装はこちらです。

BaseInputs.tsx
import { FC } from "react"
import { FieldErrorsImpl, UseFormRegister } from "react-hook-form"

import { InputName } from "../types/inputName"
import { User } from "../types/user"
import { ErrorMessage } from "./ErrorMessage"
import { Input } from "./Input"

type Props = {
  register: UseFormRegister<User>
  errors: Partial<FieldErrorsImpl<User>>
  formItems: { name: InputName, label: string }[]
}

export const BaseInputs: FC<Props> = ({ register, errors, formItems }) => (
  <>
    {formItems.map(({ name, label }) => (
      <div key={label}>
        <Input
          register={register}
          name={name}
          label={label}
        />
        {errors[name] && <ErrorMessage />}
      </div>
    ))}
  </>
)

このBaseInputsをFormコンポーネント内でループさせます。

Form.tsx
import { FC } from "react"
import { SubmitHandler, useForm } from "react-hook-form"

import { InputName } from "../types/inputName"
import { User } from "../types/user"
import { BaseInputs } from "./BaseInputs"

type FormItems = { name: InputName, label: string }[]

const firstFormItems: FormItems = [
  { name: 'nickname', label: 'ニックネーム' },
  { name: 'firstName', label: '名前' },
  { name: 'lastName', label: '名字' }
]
const secondFormItems: FormItems = [
  { name: 'email', label: 'メールアドレス' },
  { name: 'phoneNumber', label: '電話番号' }
]
const lastFormItems: FormItems = [
  { name: 'password', label: 'パスワード' },
  { name: 'passwordConfirmation', label: '確認用' },
]

const formItems: FormItems[] = [
  firstFormItems,
  secondFormItems,
  lastFormItems
]

const Form: FC = () => {
  const {
    handleSubmit,
    register,
    formState: {
      errors,
    }} = useForm<User>()

  const onSubmit: SubmitHandler<User> = (data) => console.log(data)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {formItems.map((items, index) => (
        <BaseInputs
          key={index}
          register={register}
          errors={errors}
          formItems={items}
        />
      ))}
      <input type="submit" value="提出する" />
    </form>
  )
}

export default Form

※keyにindexを渡すのはあまり良くない実装ですが一旦このまま進みます。

ここまでの実装を画面で確かめてみましょう。

66332b617e2e11717b9d2d945ecb6cdb_AdobeExpress.gif

react-hook-formのvalidationがちゃんと効いているのが確認できます。

ポイントはregisterとerrorsをpropsとして渡すことです。
BaseInputs内でuseFormを呼び出してregister、errorsを取得してしまうと各コンポーネント内のregister、errorsは異なるオブジェクトになってしまうのでvalidationがうまく走ってくれません。

いよいよWizard形式へ

前置きが長くなりましたがここからWizard形式のフォームの実装を行います。

先に実装方針から説明しておくと、useWizardFormというhooksを用意して、このhooksを使って画面に表示するコンポーネントを制御します。

useWizardFormの役割は下記です。

  • ReactElement(コンポーネント)の配列型の引数を受け取る
  • currentStepIndexというステートを保持する
  • currentStepIndexを変更する関数を提供する
  • currentStepIndexの状態によって変わるcurrentForm(画面に表示するコンポーネント)を提供する

コードを確認します。

useWizardForm.ts
import { ReactElement, useState } from "react";

export const useWizardForm = (forms: ReactElement[]) => {
  const [currentStepIndex, setCurrentStepIndex] = useState(0)

  const next = () => setCurrentStepIndex(prev => {
    if (prev === forms.length - 1) return prev
    return prev + 1
  })

  const back = () => setCurrentStepIndex(prev => {
    if (prev === 0) return prev
    return prev - 1
  })

  return {
    currentForm: forms[currentStepIndex],
    isFirstStep: currentStepIndex === 0,
    isLastStep: currentStepIndex === forms.length - 1,
    next,
    back
  }
}

returnされているプロパティを1つづつ解説していきます。

プロパティ 説明
currentForm 画面に表示されるコンポーネント。useWizardForm実行時に渡したコンポーネントの配列の中からcurrentStepIndexの状態によって返却値が変わる
isFirstStep 最初の画面かどうかを判定するフラグ
isLastStep 最後の画面かどうかを判定するフラグ
next currentStepIndexを1つ進める関数。ただし引数で受け取った配列の要素数以上にはならない。
back currentStepIndexを1つ戻す関数。ただし0未満にはならない。

「次へ」や「前へ」ボタンを押すと画面に表示されるコンポーネントが切り替わるイメージです。
isFirstStep、isLastStepは1ページ目、最後のページで表示させるボタンを切り替えるために用意しています。

実際にこれを使っていきましょう。

Form.tsx
import { FC } from "react"
import { SubmitHandler, useForm } from "react-hook-form"
import { useWizardForm } from "../hooks/useWizardForm"

import { InputName } from "../types/inputName"
import { User } from "../types/user"
import { BaseInputs } from "./BaseInputs"

type FormItems = { name: InputName, label: string }[]

const firstFormItems: FormItems = [
  { name: 'nickname', label: 'ニックネーム' },
  { name: 'firstName', label: '名前' },
  { name: 'lastName', label: '名字' }
]
const secondFormItems: FormItems = [
  { name: 'email', label: 'メールアドレス' },
  { name: 'phoneNumber', label: '電話番号' }
]
const lastFormItems: FormItems = [
  { name: 'password', label: 'パスワード' },
  { name: 'passwordConfirmation', label: '確認用' },
]

const formItems: FormItems[] = [
  firstFormItems,
  secondFormItems,
  lastFormItems
]

const Form: FC = () => {
  const {
    handleSubmit,
    register,
    formState: {
      errors
    }} = useForm<User>()
  const onSubmit: SubmitHandler<User> = (data) => console.log(data)
  
  // 1. useWizardFormの引数に渡す配列を定義
  const forms =
    formItems
      .map((items, index) => (
        <BaseInputs
          key={index}
          register={register}
          errors={errors}
          formItems={items}
        />
      ))
  // 1を引数に渡してuseWizardFormの実行
  const { currentForm, isFirstStep, isLastStep, next, back } = useWizardForm(forms)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {currentForm}
      <div>
        { /* 表示するコンポーネントを切り替えるボタン。最終ページの場合はsubmitボタンを表示 */}
        {!isFirstStep && <button type="button" onClick={back}>&lt;&lt;前へ</button>}
        {!isLastStep && <button type="button" onClick={next}>次へ&gt;&gt;</button>}
        {isLastStep && <button type="submit">提出する</button>}
      </div>
    </form>
  )
}

export default Form

これで一旦実装を確認してみましょう。

0b11d1dfe6a1f322ab0d8f1e154df39a_AdobeExpress.gif

validationがおかしなことになっていますがそれもそのはず。
次へ、前へボタンはtype="submit"ではないのでreact-hook-formのhandleSubmitが実行されないためです。

いくつか方法があるのですが今回は下記のような仕様で進めたいと思います。

  • useFormのmodeをonChangeに設定して未入力があると他のステップに進めないようにする
  • 最後のページでSubmit用のボタンを表示する

ということでコードを変更します。

Form.tsx
// 省略

const Form: FC = () => {
  const {
    handleSubmit,
    register,
    formState: {
      // buttonタグのdisabledに渡すisValid,isSubmittingを追加
      isValid,
      isSubmitting,
      errors
    }} = useForm<User>({
      // modeの変更
      mode: 'onChange'
    })
  const onSubmit: SubmitHandler<User> = (data) => console.log(data)

  const forms =
    formItems
      .map((items, index) => (
        <BaseInputs
          key={index}
          register={register}
          errors={errors}
          formItems={items}
        />
      ))

  const { currentForm, isFirstStep, isLastStep, next, back } = useWizardForm(forms)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {currentForm}
      <div>
        { /* 各ボタンにdisabledの追加 */ }
        {!isFirstStep && <button type="button" onClick={back} disabled={!isValid || isSubmitting}>&lt;&lt;前へ</button>}
        {!isLastStep && <button type="button" onClick={next} disabled={!isValid || isSubmitting}>次へ&gt;&gt;</button>}
        {isLastStep && <button type="submit" disabled={!isValid || isSubmitting}>提出する</button>}
      </div>
    </form>
  )
}

export default Form

それでは挙動を確認してみましょう。
(最後に提出するボタンを押したときのdataをJSON形式で警告表示するようにonSubmit内の処理を変更しています)

e22c857a5aef71a4f307d91eb6113c44.gif

今度はバッチリvalidationが効いていて、前に戻っても一度入力した内容が残っていることが確認できます。

まとめ

実はWizard形式のフォームの実装はreact-hook-formの公式ドキュメントにも実装例が記載されています。

https://react-hook-form.com/advanced-usage/

こちらはステップごとにURLも切り替える仕様にになっているため、同じURLで実現ができないものかと調べていたところ先述の動画に出会いました。

これにreact-hook-formを組み合わせることでかなりいい感じのフォームが簡単に作れちゃいます。
今回はスタイルを当てていませんが、daisyUIなどのリッチなUIと組み合わせればもっとイケてるフォームができるのではないでしょうか。

以上、Wizard形式のフォームの実装はいかがだったでしょうか。
分かりづらい説明などありましたらご指摘いただけますと幸いです。

1
1
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
1
1