はじめに
この記事ではReact + TypescriptでWizard形式(マルチステップ)のフォームを作るレシピをまとめています。
フォームの制御にはreact-hook-formを使用します。
react-hook-formについては別の記事でまとめているのでよかったら併せて読んでください。
React + Typescript 〜react-hook-formを使って超簡単にフォームvalidationを実装する〜 - Qiita
参考にしたサイト
こちらの動画が大変参考になりました。
解説はすべて英語ですが、とても分かりやすいので英語が聞き取れなくても理解できると思います。
完成品
1つのURLで複数の入力ステップを踏むフォームを実装します。
今回はユーザー情報の登録を想定して下記のようなデータ構造で実装を進めます。
type User = {
nickname: string
firstName: string
lastName: string
email: string
phoneNumber: string
password: string
passwordConfirmation: string
}
尚、この記事はWizard形式のフォームの実装にフォーカスしているため、react-hook-formのvalidationはすべて入力必須に留めます。
実装の準備
まずは一画面に全てのinputを表示していきます。
各inputとエラーメッセージは重複するためコンポーネントに切り出しています。
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
export type InputName =
| 'nickname'
| 'lastName'
| 'firstName'
| 'email'
| 'phoneNumber'
| 'password'
| 'passwordConfirmation'
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>
)
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として抽出してあげます。
最終的な実装はこちらです。
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コンポーネント内でループさせます。
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を渡すのはあまり良くない実装ですが一旦このまま進みます。
ここまでの実装を画面で確かめてみましょう。
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(画面に表示するコンポーネント)を提供する
コードを確認します。
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ページ目、最後のページで表示させるボタンを切り替えるために用意しています。
実際にこれを使っていきましょう。
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}><<前へ</button>}
{!isLastStep && <button type="button" onClick={next}>次へ>></button>}
{isLastStep && <button type="submit">提出する</button>}
</div>
</form>
)
}
export default Form
これで一旦実装を確認してみましょう。
validationがおかしなことになっていますがそれもそのはず。
次へ、前へボタンはtype="submit"
ではないのでreact-hook-formのhandleSubmitが実行されないためです。
いくつか方法があるのですが今回は下記のような仕様で進めたいと思います。
- useFormのmodeをonChangeに設定して未入力があると他のステップに進めないようにする
- 最後のページでSubmit用のボタンを表示する
ということでコードを変更します。
// 省略
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}><<前へ</button>}
{!isLastStep && <button type="button" onClick={next} disabled={!isValid || isSubmitting}>次へ>></button>}
{isLastStep && <button type="submit" disabled={!isValid || isSubmitting}>提出する</button>}
</div>
</form>
)
}
export default Form
それでは挙動を確認してみましょう。
(最後に提出するボタンを押したときのdataをJSON形式で警告表示するようにonSubmit内の処理を変更しています)
今度はバッチリvalidationが効いていて、前に戻っても一度入力した内容が残っていることが確認できます。
まとめ
実はWizard形式のフォームの実装はreact-hook-formの公式ドキュメントにも実装例が記載されています。
こちらはステップごとにURLも切り替える仕様にになっているため、同じURLで実現ができないものかと調べていたところ先述の動画に出会いました。
これにreact-hook-formを組み合わせることでかなりいい感じのフォームが簡単に作れちゃいます。
今回はスタイルを当てていませんが、daisyUIなどのリッチなUIと組み合わせればもっとイケてるフォームができるのではないでしょうか。
以上、Wizard形式のフォームの実装はいかがだったでしょうか。
分かりづらい説明などありましたらご指摘いただけますと幸いです。