アクセシビリティ (A11y)
React Hook Formは、ネイティブのフォーム検証をサポートしており、独自のルールで入力を検証できます。ほとんどの場合、カスタムデザインとレイアウトでフォームを構築する必要があるため、それらがアクセシブル(A11y)であることを確認するのは私たちの責任です。
次のコード例は、検証のために意図したとおりに機能しますが、アクセシビリティのために改善することができます。
import React from "react"import { useForm } from "react-hook-form"export default function App() {const {register,handleSubmit,formState: { errors },} = useForm()const onSubmit = (data) => console.log(data)return (<form onSubmit={handleSubmit(onSubmit)}><label htmlFor="name">Name</label><inputid="name"{...register("name", { required: true, maxLength: 30 })}/>{errors.name && errors.name.type === "required" && (<span>This is required</span>)}{errors.name && errors.name.type === "maxLength" && (<span>Max length exceeded</span>)}<input type="submit" /></form>)}
次のコード例は、ARIAを活用して改善されたバージョンです。
import { useForm } from "react-hook-form"export default function App() {const {register,handleSubmit,formState: { errors },} = useForm()const onSubmit = (data) => console.log(data)return (<form onSubmit={handleSubmit(onSubmit)}><label htmlFor="name">Name</label>{/* use aria-invalid to indicate field contain error */}<inputid="name"aria-invalid={errors.name ? "true" : "false"}{...register("name", { required: true, maxLength: 30 })}/>{/* use role="alert" to announce the error message */}{errors.name && errors.name.type === "required" && (<span role="alert">This is required</span>)}{errors.name && errors.name.type === "maxLength" && (<span role="alert">Max length exceeded</span>)}<input type="submit" /></form>)}
この改善後、スクリーンリーダーは「名前、編集、無効なエントリ、必須です。」と読み上げます。
ウィザードフォーム / ファネル
さまざまなページやセクションを通じてユーザー情報を収集するのはごく一般的です。さまざまなページやセクションを通じてユーザー入力を保存するには、状態管理ライブラリを使用することをお勧めします。この例では、状態管理ライブラリとしてlittle state machineを使用します(慣れている場合は、reduxに置き換えることができます)。
ステップ1:ルートとストアを設定します。
import { BrowserRouter as Router, Route } from "react-router-dom"import { StateMachineProvider, createStore } from "little-state-machine"import Step1 from "./Step1"import Step2 from "./Step2"import Result from "./Result"createStore({data: {firstName: "",lastName: "",},})export default function App() {return (<StateMachineProvider><Router><Route exact path="/" component={Step1} /><Route path="/step2" component={Step2} /><Route path="/result" component={Result} /></Router></StateMachineProvider>)}
ステップ2:ページを作成し、データを収集してストアに送信し、次のフォーム/ページにプッシュします。
import { useForm } from "react-hook-form"import { withRouter } from "react-router-dom"import { useStateMachine } from "little-state-machine"import updateAction from "./updateAction"const Step1 = (props) => {const { register, handleSubmit } = useForm()const { actions } = useStateMachine({ updateAction })const onSubmit = (data) => {actions.updateAction(data)props.history.push("./step2")}return (<form onSubmit={handleSubmit(onSubmit)}><input {...register("firstName")} /><input {...register("lastName")} /><input type="submit" /></form>)}export default withRouter(Step1)
ステップ3:ストア内のすべてのデータで最終的な送信を行うか、結果のデータを表示します。
import { useStateMachine } from "little-state-machine"import updateAction from "./updateAction"const Result = (props) => {const { state } = useStateMachine(updateAction)return <pre>{JSON.stringify(state, null, 2)}</pre>}
上記のパターンに従うことで、複数のページからユーザー入力データを収集するためのウィザードフォーム/ファネルを構築できるはずです。
スマートフォームコンポーネント
ここでのアイデアは、入力を利用してフォームを簡単に構成できるということです。Form
コンポーネントを作成して、フォームデータを自動的に収集します。
import { Form, Input, Select } from "./Components"export default function App() {const onSubmit = (data) => console.log(data)return (<Form onSubmit={onSubmit}><Input name="firstName" /><Input name="lastName" /><Select name="gender" options={["female", "male", "other"]} /><Input type="submit" value="Submit" /></Form>)}
これらの各コンポーネントの内容を見てみましょう。
</> フォーム
Form
コンポーネントの役割は、react-hook-form
のすべてのメソッドを子コンポーネントに注入することです。
import React from "react"import { useForm } from "react-hook-form"export default function Form({ defaultValues, children, onSubmit }) {const methods = useForm({ defaultValues })const { handleSubmit } = methodsreturn (<form onSubmit={handleSubmit(onSubmit)}>{React.Children.map(children, (child) => {return child.props.name? React.createElement(child.type, {...{...child.props,register: methods.register,key: child.props.name,},}): child})}</form>)}
</> 入力/選択
これらの入力コンポーネントの役割は、react-hook-form
に登録することです。
import React from "react"export function Input({ register, name, ...rest }) {return <input {...register(name)} {...rest} />}export function Select({ register, options, name, ...rest }) {return (<select {...register(name)} {...rest}>{options.map((value) => (<option key={value} value={value}>{value}</option>))}</select>)}
Form
コンポーネントがreact-hook-form
のprops
を子コンポーネントに注入することで、アプリで複雑なフォームを簡単に作成および構成できます。
エラーメッセージ
エラーメッセージは、入力に問題がある場合にユーザーに表示されるフィードバックです。React Hook Formは、エラーを簡単に取得できるようにerrors
オブジェクトを提供します。画面上のエラー表示を改善する方法はいくつかあります。
-
登録
検証ルールオブジェクトの
message
属性を使用して、register
にエラーメッセージを簡単に渡すことができます。このように<input {...register('test', { maxLength: { value: 2, message: "エラーメッセージ" } })} />
-
オプションチェーン
?.
オプションチェーン演算子を使用すると、null
またはundefined
が原因で別のエラーが発生することを心配せずに、errors
オブジェクトを読み取ることができます。errors?.firstName?.message
-
Lodash
get
プロジェクトでlodashを使用している場合は、lodashのget関数を活用できます。例
get(errors, 'firstName.message')
フォームを接続する
フォームを構築する場合、入力が深くネストされたコンポーネントツリー内に存在することがあり、その場合にFormContextが役立ちます。ただし、ConnectForm
コンポーネントを作成し、ReactのrenderPropsを活用することで、開発者エクスペリエンスをさらに向上させることができます。利点は、入力をReact Hook Formにはるかに簡単に接続できることです。
import { FormProvider, useForm, useFormContext } from "react-hook-form"export const ConnectForm = ({ children }) => {const methods = useFormContext()return children({ ...methods })}export const DeepNest = () => (<ConnectForm>{({ register }) => <input {...register("deepNestedInput")} />}</ConnectForm>)export const App = () => {const methods = useForm()return (<FormProvider {...methods}><form><DeepNest /></form></FormProvider>)}
FormProviderのパフォーマンス
React Hook FormのFormProviderは、ReactのコンテキストAPIに基づいて構築されています。これにより、すべてのレベルでpropsを手動で渡すことなく、コンポーネントツリーを介してデータが渡されるという問題が解決します。これにより、React Hook Formが状態の更新をトリガーすると、コンポーネントツリーが再レンダリングされますが、必要に応じて以下の例でアプリを最適化できます。
注:React Hook Formの開発者ツールをFormProviderと組み合わせて使用すると、状況によってはパフォーマンスの問題が発生する可能性があります。パフォーマンスの最適化に深く入り込む前に、このボトルネックを最初に検討してください。
import React, { memo } from "react"import { useForm, FormProvider, useFormContext } from "react-hook-form"// we can use React.memo to prevent re-render except isDirty state changedconst NestedInput = memo(({ register, formState: { isDirty } }) => (<div><input {...register("test")} />{isDirty && <p>This field is dirty</p>}</div>),(prevProps, nextProps) =>prevProps.formState.isDirty === nextProps.formState.isDirty)export const NestedInputContainer = ({ children }) => {const methods = useFormContext()return <NestedInput {...methods} />}export default function App() {const methods = useForm()const onSubmit = (data) => console.log(data)console.log(methods.formState.isDirty) // make sure formState is read before render to enable the Proxyreturn (<FormProvider {...methods}><form onSubmit={methods.handleSubmit(onSubmit)}><NestedInputContainer /><input type="submit" /></form></FormProvider>)}
制御コンポーネントと非制御コンポーネントの混在
React Hook Formは非制御コンポーネントを採用していますが、制御コンポーネントとも互換性があります。ほとんどのUIライブラリは、MUIやAntdなど、制御コンポーネントのみをサポートするように構築されています。しかし、React Hook Formを使用すると、制御コンポーネントの再レンダリングも最適化されます。これは、検証と両方を組み合わせた例です。
import React, { useEffect } from "react"import { Input, Select, MenuItem } from "@material-ui/core"import { useForm, Controller } from "react-hook-form"const defaultValues = {select: "",input: "",}function App() {const { handleSubmit, reset, watch, control, register } = useForm({defaultValues,})const onSubmit = (data) => console.log(data)return (<form onSubmit={handleSubmit(onSubmit)}><Controllerrender={({ field }) => (<Select {...field}><MenuItem value={10}>Ten</MenuItem><MenuItem value={20}>Twenty</MenuItem></Select>)}control={control}name="select"defaultValue={10}/><Input {...register("input")} /><button type="button" onClick={() => reset({ defaultValues })}>Reset</button><input type="submit" /></form>)}
リゾルバー付きカスタムフック
カスタムフックをリゾルバーとして構築できます。カスタムフックは、yup/Joi/Superstructを検証方法として簡単に統合し、検証リゾルバー内で使用できます。
- メモ化された検証スキーマを定義します(または、依存関係がない場合はコンポーネントの外で定義します)
- 検証スキーマを渡して、カスタムフックを使用します
- 検証リゾルバーをuseFormフックに渡します
import React, { useCallback, useMemo } from "react"import { useForm } from "react-hook-form"import * as yup from "yup"const useYupValidationResolver = (validationSchema) =>useCallback(async (data) => {try {const values = await validationSchema.validate(data, {abortEarly: false,})return {values,errors: {},}} catch (errors) {return {values: {},errors: errors.inner.reduce((allErrors, currentError) => ({...allErrors,[currentError.path]: {type: currentError.type ?? "validation",message: currentError.message,},}),{}),}}},[validationSchema])const validationSchema = yup.object({firstName: yup.string().required("Required"),lastName: yup.string().required("Required"),})export default function App() {const resolver = useYupValidationResolver(validationSchema)const { handleSubmit, register } = useForm({ resolver })return (<form onSubmit={handleSubmit((data) => console.log(data))}><input {...register("firstName")} /><input {...register("lastName")} /><input type="submit" /></form>)}
仮想化リストの操作
データの表があると想像してください。この表には、数百または数千の行が含まれる可能性があり、各行には入力があります。一般的な方法は、ビューポートにある項目のみをレンダリングすることですが、これにより、項目がビューから外れるとDOMから削除され、再度追加されるため、問題が発生します。これにより、項目がビューポートに再度入ったときにデフォルト値にリセットされます。
react-windowを使用した以下の例を示します。
import React from "react"import { FormProvider, useForm, useFormContext } from "react-hook-form"import { VariableSizeList as List } from "react-window"import AutoSizer from "react-virtualized-auto-sizer"import ReactDOM from "react-dom"import "./styles.css"const items = Array.from(Array(1000).keys()).map((i) => ({title: `List ${i}`,quantity: Math.floor(Math.random() * 10),}))const WindowedRow = React.memo(({ index, style, data }) => {const { register } = useFormContext()return <input {...register(`${index}.quantity`)} />})export const App = () => {const onSubmit = (data) => console.log(data)const formMethods = useForm({ defaultValues: items })return (<form className="form" onSubmit={formMethods.handleSubmit(onSubmit)}><FormProvider {...formMethods}><AutoSizer>{({ height, width }) => (<Listheight={height}itemCount={items.length}itemSize={() => 100}width={width}itemData={items}>{WindowedRow}</List>)}</AutoSizer></FormProvider><button type="submit">Submit</button></form>)}
フォームのテスト
テストは、コードにバグやミスがないようにするため非常に重要です。また、コードベースをリファクタリングする際にコードの安全性を保証します。
シンプルで、テストがユーザーの動作に重点を置いているため、testing-libraryを使用することをお勧めします。
ステップ1:テスト環境を設定します。
react-hook-formはMutationObserver
を使用して入力を検出し、DOMからアンマウントするため、jest
の最新バージョンで@testing-library/jest-domをインストールしてください。
注:React Nativeを使用している場合は、@testing-library/jest-domをインストールする必要はありません。
npm install -D @testing-library/jest-dom
@testing-library/jest-domをインポートするためにsetup.js
を作成します。
import "@testing-library/jest-dom"
注:React Nativeを使用している場合は、setup.jsを作成し、window
オブジェクトを定義して、セットアップファイルに次の行を含める必要があります
global.window = {}global.window = global
最後に、jest.config.js
のsetup.js
を更新して、ファイルを含める必要があります。
module.exports = {setupFilesAfterEnv: ["<rootDir>/setup.js"], // or .ts for TypeScript App// ...other settings}
さらに、eslint-plugin-testing-libraryとeslint-plugin-jest-domを設定して、ベストプラクティスに従い、テストを作成する際の一般的なミスを予測できます。
ステップ2:ログインフォームを作成します。
role属性を適切に設定しました。これらの属性は、テストを作成するときに役立ち、アクセシビリティを向上させます。詳細については、testing-libraryのドキュメントを参照してください。
import React from "react"import { useForm } from "react-hook-form"export default function App({ login }) {const {register,handleSubmit,formState: { errors },reset,} = useForm()const onSubmit = async (data) => {await login(data.email, data.password)reset()}return (<form onSubmit={handleSubmit(onSubmit)}><label htmlFor="email">email</label><inputid="email"{...register("email", {required: "required",pattern: {value: /\S+@\S+\.\S+/,message: "Entered value does not match email format",},})}type="email"/>{errors.email && <span role="alert">{errors.email.message}</span>}<label htmlFor="password">password</label><inputid="password"{...register("password", {required: "required",minLength: {value: 5,message: "min length is 5",},})}type="password"/>{errors.password && <span role="alert">{errors.password.message}</span>}<button type="submit">SUBMIT</button></form>)}
ステップ3:テストを作成します。
テストでカバーしようとする基準は次のとおりです
-
送信失敗をテストします。
handleSubmit
メソッドは非同期で実行されるため、送信フィードバックを検出するためにwaitFor
ユーティリティとfind*
クエリを使用しています。 -
各入力に関連付けられた検証をテストします。
ユーザーがUIコンポーネントを認識する方法であるため、さまざまな要素をクエリするときに
*ByRole
メソッドを使用しています。 -
正常な送信をテストします。
import React from "react"import { render, screen, fireEvent, waitFor } from "@testing-library/react"import App from "./App"const mockLogin = jest.fn((email, password) => {return Promise.resolve({ email, password })})it("should display required error when value is invalid", async () => {render(<App login={mockLogin} />)fireEvent.submit(screen.getByRole("button"))expect(await screen.findAllByRole("alert")).toHaveLength(2)expect(mockLogin).not.toBeCalled()})it("should display matching error when email is invalid", async () => {render(<App login={mockLogin} />)fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {target: {value: "test",},})fireEvent.input(screen.getByLabelText("password"), {target: {value: "password",},})fireEvent.submit(screen.getByRole("button"))expect(await screen.findAllByRole("alert")).toHaveLength(1)expect(mockLogin).not.toBeCalled()expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("test")expect(screen.getByLabelText("password")).toHaveValue("password")})it("should display min length error when password is invalid", async () => {render(<App login={mockLogin} />)fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {target: {value: "test@mail.com",},})fireEvent.input(screen.getByLabelText("password"), {target: {value: "pass",},})fireEvent.submit(screen.getByRole("button"))expect(await screen.findAllByRole("alert")).toHaveLength(1)expect(mockLogin).not.toBeCalled()expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("test@mail.com")expect(screen.getByLabelText("password")).toHaveValue("pass")})it("should not display error when value is valid", async () => {render(<App login={mockLogin} />)fireEvent.input(screen.getByRole("textbox", { name: /email/i }), {target: {value: "test@mail.com",},})fireEvent.input(screen.getByLabelText("password"), {target: {value: "password",},})fireEvent.submit(screen.getByRole("button"))await waitFor(() => expect(screen.queryAllByRole("alert")).toHaveLength(0))expect(mockLogin).toBeCalledWith("test@mail.com", "password")expect(screen.getByRole("textbox", { name: /email/i })).toHaveValue("")expect(screen.getByLabelText("password")).toHaveValue("")})
テスト中のact警告の解決
react-hook-formを使用するコンポーネントをテストする場合、そのコンポーネントに非同期コードを記述していない場合でも、次のような警告が発生する可能性があります
警告:テスト内のMyComponentへの更新はact(...)でラップされていません
import React from "react"import { useForm } from "react-hook-form"export default function App() {const { register, handleSubmit, formState } = useForm({mode: "onChange",})const onSubmit = (data) => {}return (<form onSubmit={handleSubmit(onSubmit)}><input{...register("answer", {required: true,})}/><button type="submit">SUBMIT</button></form>)}
import React from "react"import { render, screen } from "@testing-library/react"import App from "./App"it("should have a submit button", () => {render(<App />)expect(screen.getByText("SUBMIT")).toBeInTheDocument()})
この例では、明らかな非同期コードがない単純なフォームがあり、テストではコンポーネントをレンダリングしてボタンの存在をテストするだけです。ただし、更新がact()
でラップされていないことに関する警告が引き続きログに記録されます。
これは、react-hook-formが内部で非同期検証ハンドラーを使用しているためです。formStateを計算するために、最初にフォームを検証する必要があり、これは非同期で行われ、別のレンダリングが発生します。その更新はテスト関数が戻った後に発生し、警告がトリガーされます。
これを解決するには、find*
クエリを使用してUIの要素が表示されるまで待ちます。render()
の呼び出しをact()
でラップしないようにしてください。act
で不要なものをラップすることについての詳細はこちらを参照してください。
import React from "react"import { render, screen } from "@testing-library/react"import App from "./App"it("should have a submit button", async () => {render(<App />)expect(await screen.findByText("SUBMIT")).toBeInTheDocument()// Now that the UI was awaited until the async behavior was completed,// you can keep asserting with `get*` queries.expect(screen.getByRole("textbox")).toBeInTheDocument()})
変換と解析
ネイティブのinputは、valueAsNumber
またはvalueAsDate
を指定しない限り、値をstring
形式で返します。詳細については、このセクションを参照してください。しかし、これは完璧ではありません。isNaN
やnull
の値にも対処する必要があります。そのため、変換処理はカスタムフックのレベルで行う方が良いでしょう。次の例では、Controller
を使用して、入力値と出力値の変換機能を組み込んでいます。カスタムのregister
でも同様の結果を得ることができます。
const ControllerPlus = ({control,transform,name,defaultValue}) => (<ControllerdefaultValue={defaultValue}control={control}name={name}render={({ field }) => (<inputonChange={(e) => field.onChange(transform.output(e))}value={transform.input(field.value)}/>)}/>);// usage below:<ControllerPlus<string, number>transform={{input: (value) =>isNaN(value) || value === 0 ? "" : value.toString(),output: (e) => {const output = parseInt(e.target.value, 10);return isNaN(output) ? 0 : output;}}}control={control}name="number"defaultValue=""/>
ご支援ありがとうございます。
もしReact Hook Formがあなたのプロジェクトで役立つと感じたら、スターを付けて応援してください。