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, errors } = useForm();
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label htmlFor="name">Name</label>
      <input type="text" id="name" name="name" ref={register({ 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, 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
        type="text"
        id="name"
        name="name"
        aria-invalid={errors.name ? "true" : "false"}
        ref={register({ 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.”


ウィザードフォーム・ファンネル

In this video tutorial, I have demonstrated the core concept of how to build multiple steps funnel with React Hook Form.

異なるページやセクション間でユーザーの情報を収集することは非常に一般的です。 このような場合、異なるページやセクション間でのユーザーの入力値を、 状態管理ライブラリを使用して保存しておくことをお勧めします。 この例では、状態管理ライブラリとして little state machine (より身近なものであれば、 redux として置き換えることができます) を使用します。

ステップ1: ルーティングとストアを設定します。

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

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 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 { action } = useStateMachine(updateAction);
  const onSubmit = data => {
    action(data);
    props.history.push("./step2");
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input name="firstName" ref={register} />
      <input name="lastName" ref={register} />
      <input type="submit" />
    </form>
  );
};

export default withRouter(Step1);

ステップ3: 最終的に、ストア内のすべてのフォームデータを使用して、 フォームを送信したりフォームデータの結果を表示します。

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

上記のパターンに従って、複数のページ間でのユーザーの入力データを収集して、 ウィザードフォーム・ファンネルを構築できるはずです。


スマートフォームコンポーネント

ここでのアイデアは、input とフォームを簡単に組み合わせることができるということです。Form コンポーネントを作成して、フォームデータを自動的に収集します。

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

Input / Select コンポーネントの責任は、自分自身を react-hook-form に登録することです。

import React from "react";

export function Input({ register, name, ...rest }) {
  return <input name={name} ref={register} {...rest} />;
}

export function Select({ register, options, name, ...rest }) {
  return (
    <select name={name} ref={register} {...rest}>
      {options.map(value => (
        <option key={value} value={value}>
          {value}
        </option>
      ))}
    </select>
  );
}

Form コンポーネントを使用して、react-hook-formprops を子コンポーネントに注入することで、 アプリケーションで複雑なフォームを簡単に作成及び組み合わせることができます。


フィールド配列

この機能は、React Hook Formが提供する最良の機能の一つです。 この機能を実現するために (他のライブラリのように) コンポーネントをインポートする代わりに、 既存の HTML マークアップを活用することができます。 key は、 name 属性にあります。 React Hook Form において、 name 属性はあなたが使用したいデータ構造を表します。

注意: 私たちは、複雑なシナリオのためのカスタムフック useFieldArray も作成しました。

下記の例は、input の name 属性を操作してどのようにフィールド配列を作成できるかを示しています。

注意: アプリケーションにフィールドの削除や挿入、追加、先頭に追加などの機能が必要な場合は、 Controller を使用した実装のリンクを参照して下さい。

import React from "react";
import { useForm, useFieldArray } from "react-hook-form";

function App() {
  const { register, control, handleSubmit, reset, trigger, setError } = useForm({
    // defaultValues: {}; you can populate the fields by this attribute 
  });
  const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
    control,
    name: "test"
  });
  
  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <ul>
        {fields.map((item, index) => (
          <li key={item.id}>
            <input
              name={`test[${index}].firstName`}
              ref={register()}
              defaultValue={item.firstName} // make sure to set up defaultValue
            />
            <Controller
              as={<input />}
              name={`test[${index}].lastName`}
              control={control}
              defaultValue={item.lastName} // make sure to set up defaultValue
            />
            <button type="button" onClick={() => remove(index)}>Delete</button>
          </li>
        ))}
      </ul>
      <button
        type="button"
        onClick={() => append({ firstName: "appendBill", lastName: "appendLuo" })}
      >
        append
      </button>
      <input type="submit" />
    </form>
  );
}
import React, { useState } from "react";
import { useForm } from "react-hook-form";

function createArrayWithNumbers(length) {
  return Array.from({ length }, (_, k) => k + 1);
}

export default function App() {
  const { register, handleSubmit } = useForm();
  const [size, setSize] = useState(1);
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {createArrayWithNumbers(size).map(index => {
        return (
          <>
            <label htmlFor="firstName">First Name</label>
            <input
              name={`firstName[${index}]`}
              placeholder="first name"
              ref={register({ required: true })}
            />
            
            <label htmlFor="lastName">Last Name</label>
            <input
              name={`lastName[${index}]`}
              placeholder="last name"
              ref={register({ required: true })}
            />
          </>
        );
      })}

      <button type="button" onClick={() => setSize(size + 1)} >
        Add Person
      </button>
      
      <input type="submit" />
    </form>
  );
}

エラーメッセージ

エラーメッセージは、入力に関連する問題があるときにユーザーに視覚的なフィードバックを与えることです。 React Hook Form では、エラーを簡単に取得できるように errors オブジェクトを提供しています。 ただし、画面のレンダリングエラーを改善する方法はいくつかあります。

  • Register

    register 時にエラーメッセージを埋め込み、 value 属性にエラーメッセージを簡単に挿入することができます。例:

    <input name="test" ref={register({ 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 ref={register} name="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 name="test" ref={register} />
      {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 を使用すると、条件付きフィールドを非常にシンプルに扱えます。 input がコンポーネントツリーから削除されると、自動的に unregister されるからです。 そのような動作をこちらの例に示します 。ただし、制御されたコンポーネントでは ref が登録されていないため、 同じように自動的に登録解除されません。 対処方法は下記の通りです。

  • Controller をインポートしてコンポーネントをラップし、 登録および登録解除を管理できるようにします

  • useEffect を使用して、カスタム登録として input を登録し、 コンポーネントのアンマウント後に登録を解除します

下記に例を示します:

import * as React from "react";
import { useForm } from "react-hook-form";
import { ModalForm } from "./ModalForm";

type FormValues = {
  toggle: boolean;
  mail: string;
  ghost: string;
  keepValue: string;
};

export default function App() {
  const [modalFormData, setModalFormData] = React.useState("");
  const { watch, register, setValue, getValues, handleSubmit } = useForm<FormValues>();
  const [showModal, setShowModal] = React.useState(false);
  const { toggle, mail } = watch();
  
  React.useEffect(() => {
    setValue("mail", modalFormData);
  }, [setValue, modalFormData]);

  const onSubmit = (data: FormValues) => console.log(data);

  return (
    <>
      <form onSubmit={handleSubmit(onSubmit)}>
        <input type="checkbox" name="toggle" ref={register} />
        {toggle && (
          <button type="button" onClick={() => setShowModal(!showModal)}>
            Show Modal
          </button>
        )}

        <input name="mail" placeholder="mail" ref={register} />

        <input
          name="keepValue"
          placeholder="keepValue"
          ref={register}
          style={{
            display: toggle ? "block" : "none" // toggle the visbility of an input
          }}
        />

        <input type="submit" />
      </form>

      {/* working with a modal pop up, make sure to create separate form */}
      {showModal && (
        <ModalForm mail={mail} setModalFormData={setModalFormData} />
      )}
    </>
  );
}
import React, { useEffect } from "react";
import { useForm, Controller } from "react-hook-form";
import { TextField } from "@material-ui/core";

function App() {
  const { register, handleSubmit, setValue, watch, control } = useForm();
  const name = watch("name");
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>

      <label>Name:</label>
      <input ref={register} name="name" />

      <label>Conditional Field:</label>
      {name !== "bill" && (
        <Controller
          name="test" 
          as={TextField}
          control={control} 
          defaultValue=""
        />
      )}

      <input type="submit" />
    </form>
  );
}
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";

const CustomInput = React.memo(({ register, unregister, setValue, name }) => {
  useEffect(() => {
    register({ name });
    return () => unregister(name);
  }, [name, register, unregister]);
  
  return <input onChange={e => setValue(name, e.target.value)} />;
});

function App() {
  const { register, unregister, handleSubmit, setValue, watch } = useForm();
  const name = watch("name");
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h1>Unregister Controlled Component</h1>

      <label>Name:</label>
      <input ref={register} name="name" />

      <label>Conditional Field:</label>
      {name !== "bill" && (
        <CustomInput {...{ register, unregister, setValue, name: "test" }} />
      )}

      <input type="submit" />
    </form>
  );
}

制御されたコンポーネントと非制御コンポーネントの組み合わせ

React Hook Form は、非制御コンポーネントをサポートしていますが、 制御されたコンポーネントとも互換性があります。 Material-UI 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 } = useForm({ defaultValues });
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        as={
          <Select>
            <MenuItem value={10}>Ten</MenuItem>
            <MenuItem value={20}>Twenty</MenuItem>
          </Select>
        }
        control={control}
        name="select"
        defaultValue={10}
      />
      
      <Input inputRef={register} name="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>
      <br />
      <Input inputRef={register} name="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]
  );

export default function App() {
  const validationSchema = useMemo(
    () =>
      yup.object({
        firstName: yup.string().required("Required"),
        lastName: yup.string().required("Required")
      }),
    []
  );
  const resolver = useYupValidationResolver(validationSchema);
  const { handleSubmit, register } = useForm({ resolver });

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <input name="firstName" ref={register} />
      <input name="lastName" ref={register} />
      <input type="submit" />
    </form>
  );
}


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

データの表があるシナリオを想像してください。 この表は100または1000以上の列を含み、 それぞれの列には入力欄があります。 一般的にはビューポート内にあるアイテムのみをレンダリングしますが、 これはアイテムがビューの外に出た時にDOMから削除されて、 再追加されるため問題が発生します。 これはアイテムが再びビューポートに入った時に、 アイテムがデフォルトの値にリセットされる原因となります。

この問題を回避するためには、 手動でフィールドを登録し、 プログラムによってフィールドの値をセットします。

以下に react-window を使用した例を示します。

import React from "react";
import { FormContext, useForm, useFormContext } from "react-hook-form";
import { VariableSizeList as List } from "react-window";

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, setValue }) => {
  const qtyKey = "[${index}].quantity";
  const qty = getValues()[index].quantity ?? items[index].quantity;

  return (
    <input
      // Rather than ref={register}, we use defaultValue and setValue
      defaultValue={qty}
      onChange={(e) => {
        setValue(
          qtyKey,
          isNaN(Number(e.target.value)) ? 0 : Number(e.target.value)
        );
      }}
    />
  );
});

export default React.memo(({ items }) => {
  const formMethods = useForm({ defaultValues: items });
  const onSubmit = (data) => console.log(data);

  // We manually call register here for each field.
  React.useEffect(() => {
    items.forEach((item, idx) => {
      formMethods.register("[${idx}].quantity");
    });
  }, [formMethods, items]);

  return (
    <form onSubmit={formMethods.handleSubmit(onSubmit)}>
      <List
        itemCount={items.length}
        itemSize={() => 50}
        itemData={items}
        {{ ...formMethods }}
      >
        {WindowedRow}
      </List>
      <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
    },
    shouldUnregister: false
  });
  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
              as={<input />}
              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, 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"
        name="email"
        ref={register({
          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"
        name="password"
        ref={register({
          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("");
  });
});

Strictly Typed

厳密な入力フォームを構築することは、登録時に柔軟な名前属性の性質上、困難な場合があります。これを可能にするためのプラグインを追加しました。

npm install @hookform/strictly-typed

入力を TypedController でラップする必要があります。 入力された文字列名を配列の形に変換します。

import { useTypedController } from '@hookform/strictly-typed';
import { useForm } from 'react-hook-form';
import { TextField, Checkbox } from '@material-ui/core';

type FormValues = {
  flat: string;
  nested: {
    object: { test: string };
    array: { test: boolean }[];
  };
};

export default function App() {
  const { control, handleSubmit } = useForm<FormValues>();
  const TypedController = useTypedController<FormValues>({ control });

  const onSubmit = handleSubmit((data) => console.log(data));

  return (
    <form onSubmit={onSubmit}>
      <TypedController
        name="flat"
        defaultValue=""
        render={(props) => <TextField {...props} />}
      />

      <TypedController
        as="textarea"
        name={['nested', 'object', 'test']}
        defaultValue=""
        rules={{ required: true }}
      />

      <TypedController
        name={['nested', 'array', 0, 'test']}
        defaultValue={false}
        render={({ onChange, value, ...rest }) => (
          <Checkbox
            {...rest}
            onChange={e => onChange(e.target.checked)}
            checked={value}
          />
        )}
      />

      {/* ❌: Type '"notExists"' is not assignable to type 'DeepPath<FormValues, "notExists">'. */}
      <TypedController as="input" name="notExists" defaultValue="" />

      {/* ❌: Type 'number' is not assignable to type 'string | undefined'. */}
      <TypedController
        as="input"
        name={['nested', 'object', 0, 'notExists']}
        defaultValue=""
      />

      {/* ❌: Type 'true' is not assignable to type 'string | undefined'. */}
      <TypedController as="input" name="flat" defaultValue={true} />

      <input type="submit" />
    </form>
  );
}

Extend Controller

React Hook Formの強みは、プリミティブであることと シンプルな API を使用することで、より良い開発者体験と軽量化を実現します。 図書館のために。別の隠された宝石もあります。 既存の機能をより強力なものにするために、それらのAPIをコンパイルします。 を使用して、これらのプリミティブ API から派生したコンポーネントをビルドします。この中では セクションで、Controller コンポーネントを見てみましょう。 機能を拡張します。

以下は標準的なものですController:

<Controller name="test" control={control} render={props => <input {...props} />} />>

render プロップは子コンポーネントにプロップを渡します。 これらは onChange, onBlur, value です。を拡張することができます。 の機能を継承しています。isDirty, isTouched, warning のラッパーを構築することでControllerをラップするコンポーネント。


const PowerController = (props: Props) => {
  const { formState } = useFormContext(); // we are reading formState from context or you can pass down props too
  const isDirty = !!formState.dirtyFields[props.name];
  const isTouched = !!formState.touched[props.name];
  
  return (
    <Controller
      control={props.control}
      name={props.name}
      defaultValue={props.defaultValue}
      render={(innerProps) => {
        return props.render({
          ...innerProps,
          isDirty, // new isDirty prop
          isTouched,  // new isTouched prop
          warning: props.warn(innerProps.value) // include warning message user 
        });
      }}
    />
  );
};

変換と解析

ネイティブ入力の戻り値は通常 string です。 形式で呼び出されない限り、valueAsNumberまたは を使用します。valueAsDateの下で詳細を読むことができます 。本節. しかし、完璧ではありません。isNaNまたはnullの値です。のままにしておいた方が良いでしょう。 をコンポーネントレベルで変換します。次の例では の機能を含むように、Controllerを使用して 値の入力と出力を変換します。また、同様の の結果は、カスタムのregisterを使用しています。


const ControllerPlus = ({
  control,
  transform,
  name,
  defaultValue
}) => (
  <Controller
    defaultValue={defaultValue}
    control={control}
    name={name}
    render={(props) => (
      <input
        onChange={(e) => props.onChange(transform.output(e))}
        value={transform.input(props.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