Skip to content

高度な使用法

React Hook Formを使用して、複雑でアクセス可能なフォームを構築する。

アクセシビリティ (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-formprops を子コンポーネントに注入することで、 アプリケーションで複雑なフォームを簡単に作成及び組み合わせることができます。


エラーメッセージ

エラーメッセージは、入力に関連する問題があるときにユーザーに視覚的なフィードバックを与えることです。 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>
  );
}

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>
  );
}


バーチャルリストで動かす

データの表があるシナリオを想像してください。 この表は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>
  )
}
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 を使うことをお勧めします。なぜなら、テストコードはシンプルで、そしてテストはよりユーザーの行動にフォーカスしています。

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.jsjest.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 が役立つと思う場合は、リポジトリとコントリビューターをサポートするためにスターを付けてください ❤

Edit