コンテンツにスキップ

高度な使用法

複雑でアクセシブルなフォームを構築する

アクセシビリティ (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>
<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>
)
}

この改善後、スクリーンリーダーは「名前、編集、無効なエントリ、必須です。」と読み上げます。


ウィザードフォーム / ファネル

さまざまなページやセクションを通じてユーザー情報を収集するのはごく一般的です。さまざまなページやセクションを通じてユーザー入力を保存するには、状態管理ライブラリを使用することをお勧めします。この例では、状態管理ライブラリとして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 } = 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>
)
}

</> 入力/選択

これらの入力コンポーネントの役割は、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-formpropsを子コンポーネントに注入することで、アプリで複雑なフォームを簡単に作成および構成できます。


エラーメッセージ

エラーメッセージは、入力に問題がある場合にユーザーに表示されるフィードバックです。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 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は非制御コンポーネントを採用していますが、制御コンポーネントとも互換性があります。ほとんどのUIライブラリは、MUIAntdなど、制御コンポーネントのみをサポートするように構築されています。しかし、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>
)
}
import React, { useEffect } from "react"
import { Input, Select, MenuItem } from "@material-ui/core"
import { useForm } from "react-hook-form"
const defaultValues = {
select: "",
input: "",
}
function App() {
const { register, handleSubmit, setValue, reset, watch } = useForm({
defaultValues,
})
const selectValue = watch("select")
const onSubmit = (data) => console.log(data)
useEffect(() => {
register({ name: "select" })
}, [register])
const handleChange = (e) => setValue("select", e.target.value)
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Select value={selectValue} onChange={handleChange}>
<MenuItem value={10}>Ten</MenuItem>
<MenuItem value={20}>Twenty</MenuItem>
</Select>
<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 }) => (
<List
height={height}
itemCount={items.length}
itemSize={() => 100}
width={width}
itemData={items}
>
{WindowedRow}
</List>
)}
</AutoSizer>
</FormProvider>
<button type="submit">Submit</button>
</form>
)
}
import { FixedSizeList } from "react-window"
import { Controller, useFieldArray, useForm } from "react-hook-form"
const items = Array.from(Array(1000).keys()).map((i) => ({
title: `List ${i}`,
quantity: Math.floor(Math.random() * 10),
}))
function App() {
const { control, getValues } = useForm({
defaultValues: {
test: items,
},
})
const { fields, remove } = useFieldArray({ control, name: "test" })
return (
<FixedSizeList
width={400}
height={500}
itemSize={40}
itemCount={fields.length}
itemData={fields}
itemKey={(i) => fields[i].id}
>
{({ style, index, data }) => {
const defaultValue =
getValues()["test"][index].quantity ?? data[index].quantity
return (
<form style={style}>
<Controller
render={({ field }) => <input {...field} />}
name={`test[${index}].quantity`}
defaultValue={defaultValue}
control={control}
/>
</form>
)
}}
</FixedSizeList>
)
}

フォームのテスト

テストは、コードにバグやミスがないようにするため非常に重要です。また、コードベースをリファクタリングする際にコードの安全性を保証します。

シンプルで、テストがユーザーの動作に重点を置いているため、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.jssetup.jsを更新して、ファイルを含める必要があります。

module.exports = {
setupFilesAfterEnv: ["<rootDir>/setup.js"], // or .ts for TypeScript App
// ...other settings
}

さらに、eslint-plugin-testing-libraryeslint-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>
<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>
)
}

ステップ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形式で返します。詳細については、このセクションを参照してください。しかし、これは完璧ではありません。isNaNnullの値にも対処する必要があります。そのため、変換処理はカスタムフックのレベルで行う方が良いでしょう。次の例では、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 Hook Formがあなたのプロジェクトで役立つと感じたら、スターを付けて応援してください。