Skip to content

进阶

使用 React Hook Form 建设复杂且易于访问的表单.

辅助功能 (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 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>

      {/* 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: 设置你的routes和store。

import React from "react";
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: 创建页面,收集、提交数据到 store 中,并推送到下一个表单/页面。

import React from "react";
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: 最终提交 store 里的所有数据,或者展示出来。

import React from "react";
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>;
};

按照上述模式,您应该能够构建多步骤表单/漏斗以收集来自多个页面的用户输入数据。


智能表单组件

它的思想是,你可以很容易地用输入组件来组合你的表单。 我们将创建一个表单组件来自动收集表单数据。

import React from "react";
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-form's props注入到子组件中,您可以轻松地在应用中组合各种复杂的表单。


错误信息

错误信息是对用户非正确输入的可视化反馈。React Hook Form 提供的错误对象可以让你轻松地定位错误。有很多不同的方式更好地在屏幕上展示错误。

  • Register

    你可以通过校验规则对象中的message属性将错误信息传递给register, 就像这样:

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

  • Optional Chaining

    optional chaining运算符?.。允许您读取错误对象,而不必担心由于nullundefined而导致错误。

    errors?.firstName?.message

  • Lodash get

    如果你的项目中引入了 lodash, 那么可以使用 get 函数。例如:

    get(errors, 'firstName.message')


连接表单

当我们构建表单时,有时输入组件在组件树中嵌套的很深,这正是FormContext的使用场景。 然而,我们可以通过创建 ConnectionForm 组件并利用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的ContextAPI上。它解决了在组件树中数据需要逐级手动传递的问题。 但是同时它也会导致组件树在 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 优先采用非受控组件,与此同时也兼容受控组件。 大多数UI库仅限于支持受控组件,如Material-UIAnd。此外,受控组件的重渲染 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 } = 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>
  );
}


Resolver 与自定义Hook

您可以构建一个自定义hook 作为 resolver。作为校验方法,yup/Joi/Superstruct可以非常方便地集成到自定义hook中 ,并在校验 resolver 中使用。

  • 定义一个缓存的校验结构(或者在没有任何依赖的前提下定义在组件外部)
  • 传入校验结构,使用自定义hook
  • 将校验 resolver 传递给 useForm hook
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 React from "react";
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: 设置您的测试环境。

请安装@testing-library/jest-dom ,以及jest的最新版本,因为 react-hook-form 使用 MutationObserver 来探测输入组件是否从DOM中移除。

注意: 如果您使用的是React Native,你不需要安装 @testing-library/jest-dom.

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

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

注意: 如果您使用的是React Native,你需要创建 setup.js , 并定义 window对象。

import "mutationobserver-shim";

最后,你必须更新jest.config.js中的setup.js来包含该文件。

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

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

以下标准是我们在测试中试图涵盖的内容。

  • 测试提交失败。

    我们使用waitForfind*方法来 检测提交反馈,因为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 });
});

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,您可以在 下阅读更多信息。本节. 但是,这并不完美,我们还是要处理 。isNaNnull值。所以最好留下 的变换。在下面的例子中,我们是 使用Controller来包含Controller的功能。 转换值的输入和输出。您也可以实现类似的 结果与自定义注册


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在您的研究项目中有用,请支持我们和贡献者❤

Edit