跳到内容

高级用法

构建复杂且可访问的表单

可访问性 (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

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

这些输入组件的职责是将它们注册到 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 对象,让您可以轻松检索错误。有几种不同的方法可以改进屏幕上的错误呈现。

  • 注册

    您可以像这样简单地将错误消息传递给 register,通过验证规则对象的 message 属性。

    <input {...register('test', { maxLength: { value: 2, message: "error message" } })} />

  • 可选链

    ?. 可选链 运算符允许您读取 errors 对象,而无需担心由于 nullundefined 导致的另一个错误。

    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 的 Context 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: 设置您的测试环境。

请使用最新版本的 jest 安装 @testing-library/jest-dom,因为 react-hook-form 使用 MutationObserver 来检测输入,并从 DOM 中卸载。

注意: 如果您使用的是 React Native,则无需安装 @testing-library/jest-dom

npm install -D @testing-library/jest-dom

创建 setup.js 来导入 @testing-library/jest-dom

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-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: 编写测试。

以下标准是我们试图通过测试来涵盖的。

  • 测试提交失败。

    我们使用 waitFor 实用程序和 find* 查询来检测提交反馈,因为 handleSubmit 方法是异步执行的。

  • 测试与每个输入相关的验证。

    我们在查询不同的元素时使用 *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 })
})
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: {
},
})
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(
)
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: {
},
})
fireEvent.input(screen.getByLabelText("password"), {
target: {
value: "password",
},
})
fireEvent.submit(screen.getByRole("button"))
await waitFor(() => expect(screen.queryAllByRole("alert")).toHaveLength(0))
expect(mockLogin).toBeCalledWith("[email protected]", "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,它必须首先验证表单,这是异步完成的,导致另一个渲染。该更新在测试函数返回后发生,这将触发警告。

为了解决这个问题,请等待 UI 中的某些元素出现,使用 find* 查询。请注意,您 **不能** 将您的 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()
})

转换和解析

除非使用 valueAsNumbervalueAsDate 调用,否则原生输入将以 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 在您的项目中很有用,请考虑为它加星并支持它。