アクセシビリティ (A11y)
React Hook Form はネイティブフォームバリデーションをサポートします。 これにより、独自のルールで input のバリデーションを行うことができます。 私たちのほとんどはカスタムデザインとレイアウトを適用してフォームを構築しますが、 フォームのアクセシビリティ (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> <input id="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 */} <input id="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> ); }
この改良後、スクリーンリーダーはこのように話すでしょう: “Name, edit, invalid entry, This is required.”
ウィザードフォーム・ファンネル
異なるページやセクション間でユーザーの情報を収集することは非常に一般的です。 このような場合、異なるページやセクション間でのユーザーの入力値を、 状態管理ライブラリを使用して保存しておくことをお勧めします。 この例では、状態管理ライブラリとして 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>; };
上記のパターンに従って、複数のページ間でのユーザーの入力データを収集して、 ウィザードフォーム・ファンネルを構築できるはずです。
スマートフォームコンポーネント
ここでのアイデアは、input とフォームを簡単に組み合わせることができるということです。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
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 } = methods; return ( <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> ); }
Input / Select
Input / Select
コンポーネントの責任は、自分自身を 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 オブジェクトを提供しています。 ただし、画面のレンダリングエラーを改善する方法はいくつかあります。
Register
register
時にエラーメッセージを埋め込み、value
属性にエラーメッセージを簡単に挿入することができます。例:<input {...register('test', { maxLength: { value: 2, message: "error message" } })} />
Optional Chaining
Optional chaining 演算子である
?.
は、null
またはundefined
によって発生するエラーを気にせずにerrors
オブジェクトを読み取ることができます。errors?.firstName?.message
Lodash
get
プロジェクトで lodash を使用している場合、lodash の
get
関数を活用することができます。例:get(errors, 'firstName.message')
接続フォーム
フォームを作成するときに、深くネストされたコンポーネントツリーの中に input が存在することがあり、 そのような場合は FormContext が非常に便利です。ConnectForm
コンポーネントを作成して React のrenderProps を活用することで、 DX を更に向上することができます。 ConnectForm
コンポーネントの利点は、input をどこからでも 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> ); }
FormContext パフォーマンス
React Hook Form の FormContext は、 React の Context API 上に構築されています。 これにより、全ての階層で手動で props を渡す必要なく、 コンポーネントツリーを介してデータを渡す問題を解決します。 これにより、React Hook Form は状態を更新する度に、 コンポーネントツリーが再レンダリングされる問題を引き起こしますが、 必要に応じて下記の例のようにアプリを最適化することができます。
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 changed const 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 Proxy return ( <FormProvider {...methods}> <form onSubmit={methods.handleSubmit(onSubmit)}> <NestedInputContainer /> <input type="submit" /> </form> </FormProvider> ); }
制御されたコンポーネントと非制御コンポーネントの組み合わせ
React Hook Form は、非制御コンポーネントをサポートしていますが、 制御されたコンポーネントとも互換性があります。 MUI や Antd などの UI ライブラリのほとんどは、 制御されたコンポーネントのみをサポートして構築されています。 さらに、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)}> <Controller render={ ({ 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> ); }
バーチャルリストで動かす
データの表があるシナリオを想像してください。 この表は100または1000以上の列を含み、 それぞれの列には入力欄があります。 一般的にはビューポート内にあるアイテムのみをレンダリングしますが、 これはアイテムがビューの外に出た時に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 }) => ( <List height={height} itemCount={items.length} itemSize={() => 100} width={width} itemData={items}> {WindowedRow} </List> )} </AutoSizer> </FormProvider> <button type="submit">Submit</button> </form> ) }
フォームをテストする
テストはバグやミスを防いだり、 コードをリファクタリングする時にコードの安全性を保証するため、 とても重要なものです。
私たちは testing-library を使うことをお勧めします。なぜなら、テストコードはシンプルで、そしてテストはよりユーザーの行動にフォーカスしています。
♦
Step 1: テスト環境を設定する
react-hook-form は DOM からアンマウントされた input 要素を検出するために MutationObserver
を使うため @testing-library/jest-dom を jest
の最新バージョンとともにインストールしてください。
注意: React Native を使用している場合は @testing-library/jest-dom は必要ありません。
npm install -D @testing-library/jest-dom
そして @testing-library/jest-dom をインポートするために setup.js
を作成してください。
注意: React Native を使用している場合は setup.js を作成し、window
オブジェクトを定義する必要があります。
import "@testing-library/jest-dom";
最後に、setup.js
を jest.config.js
で読み込む必要があります。
module.exports = { setupFilesAfterEnv: ["<rootDir>/setup.js"] // or .ts for TypeScript App // ...other settings };
Step 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> <input id="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> <input id="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> ); }
Step 3: テストを書く
テストでカバーしようとしているのは以下の条件です:
送信時のテストに失敗
handleSubmit
は非同期で実行されるので、 送信したことを検出するためにwaitFor
やfind*
メソッドを使います。それぞれの入力に関するバリデーションをテストする
異なる要素を探す時に
*ByRole
を使います。なぜなら、このようにしてユーザーはUIコンポーネントを認識するからです。送信時のテストに成功
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 }); }); describe("App", () => { beforeEach(() => { render(<App login={mockLogin} />); }); it("should display required error when value is invalid", async () => { 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 () => { 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 }).value).toBe("test"); expect(screen.getByLabelText("password").value).toBe("password"); }); it("should display min length error when password is invalid", async () => { 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 }).value).toBe( "test@mail.com" ); expect(screen.getByLabelText("password").value).toBe("pass"); }); it("should not display error when value is valid", async () => { 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 }).value).toBe(""); expect(screen.getByLabelText("password").value).toBe(""); }); });
変換と解析
ネイティブ入力の戻り値は通常 string
です。 形式で呼び出されない限り、valueAsNumber
または を使用します。valueAsDate
の下で詳細を読むことができます 。本節. しかし、完璧ではありません。isNaN
またはnull
の値です。のままにしておいた方が良いでしょう。 をコンポーネントレベルで変換します。次の例では の機能を含むように、Controller
を使用して 値の入力と出力を変換します。また、同様の の結果は、カスタムのregister
を使用しています。
const ControllerPlus = ({ control, transform, name, defaultValue }) => ( <Controller defaultValue={defaultValue} control={control} name={name} render={({ field }) => ( <input onChange={(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 プロジェクトで React Hook Form が役立つと思う場合は、リポジトリとコントリビューターをサポートするためにスターを付けてください ❤