Reactでフォームをスマートに実装

この記事は、2021/9/2 に行われた WESEEK Tech Conference の内容をまとめたものです。

目次

はじめに

この記事ではフォームの基礎について扱い, その後 React でのフォームの基礎, 実装をしてみてライブラリ (react-hook-form) を導入する話, react-hook-form の基礎と導入例について扱います

対象読者とこの記事で扱う内容

  • React のフォームの基礎を知りたい
    • フォームってなんだろう
    • Controlled or Uncontrolled とその基礎
  • react-hook-form について知りたい
    • 基礎
      Tips, GROWI.cloud での導入事例

そもそもフォームどういうもの ?

ユーザとアプリケーションでの対話を表現しているもの。いろいろ種類がある。

一番単純なフォームから考えてみる

名前を入力したら Submit を押す, そのタイミングで入力したら何かが起こるようなシンプルなフォーム

  • コードに起こすとこういう感じ

    <form action=”/name” method=”post”>
    <label>
        Name:
        <input type="text" name="name" />
    </label>
    <input type="submit" value="Submit" />
    </form>
    • form タグで囲う
      • Submit 時の挙動を書いておく
    • input タグで, 入力欄と Submit ボタンを表現

このフォームからわかること

  • DOM 内部に状態を持っていること
    • 今回でいうとユーザが入力した Name

フォームにも種類がある

  • 左にあるのはセレクトボックス
    • ドロップダウン形式になっていて, 複数のうちから一つ選ぶようなもの
  • 右にあるフォームは名前, 出身地, メモと3つの要素をまとめて Submit するようなもの

フォームはユーザとの対話を表現することがわかる

少し複雑なフォーム

  • これは GROWI.cloud で使っているフォーム
  • 少し複雑, 構成要素が多い
    • 上下で一つのフォームであり, 「独自ドメインを利用する」を押すと切り替わる
    • 上のフォーム
      • 入力中の内容をすぐ下に表示する
      • 入力した内容が url として正しくない場合エラーを出す (validation と error 表示)
    • 下のフォーム
      • 入力した内容が url として正しくない場合エラーを出す (validation と error 表示)
      • SSL証明書 (ファイル) をアップロードする

というようにフォームの要素が増えることもある
その際, フォーム内部に持つ状態が多いので設計が大変になりやすい
またわずらわしい(e.g. 巨大, 重い)フォームを作るとユーザ離れてしまうかもしれない

-> フォームの設計は大事になる

まとめると

  • フォームはユーザとの対話
  • フォームを構成する要素が多いので設計が大事

React フォームの基礎

React のフォーム実装は2種類ある

  • Uncontrolled Component
    • フォームの状態を react の state で管理しない
      • DOM が管理する
    • 比較的高パフォーマンス
    • シンプルなフォームをシンプルに実装できる
  • Controlled Component
    • フォームの状態を react の state で管理する
    • 凝ったフォームを作りやすい

Uncontrolled Component

簡単なフォームを Uncontrolled Component で書いてみる

コードに起こす

const UncontrolledForm = () => {
    const inputRef = React.createRef<HTMLInputElement>()           -----------------  1
    const handleSubmit = event => {
        event.preventDefault();
        console.log(inputRef.current.value);                       -----------------  3
    }
    return (
        <>
            <div>
                <input ref={inputRef} type="text" name="name" />   -----------------  2
                <button onClick={handleSubmit} >Submit</button>
            </div>
        </>
    );
}

Uncontrolled Component は DOM でユーザの入力している値を管理する
簡単な3つのステップでかける

  1. inputRef として DOM ノードを監視するための reference を作成する
  2. input タグに作成した inputRef を登録してあげる
    • これで inputRef を通して input にアクセス可能
  3. submit を押したタイミングで onClick event が走り出し, inputRef を通して入力されている値を取得

このようにref を作成, input に登録, 入力されている値が必要なタイミングで DOM から取り出す
という流れになる

Uncontrolled Component のメリット, デメリット

  • メリット
    • 必要なタイミングでフィールドからとってくるだけなので軽量
    • 記述量が少ない
      • ただしシンプルなもの
    • 移植性が高い
      • HTML ネイティブの実装に近いので他のフレームワーク等への移植が容易になる
  • デメリット
    • 必要なタイミングでフィールドから取り出す方式なので, 入力ごとに何かしたい場合にこまる
      • 入力ごとに validation して error を表示する
      • ユーザの入力に対してサジェストするような場合
    • 柔軟性が薄い
      • 入力されている値を子コンポーネントにわたす場合
      • input 同士が依存関係を保つ場合
        • あるチェックボックスが押されてる場合のみに出てくるフィールド等

Controlled Component

先程と同じフォームをコードに起こしてみる

const ControlledForm = () => {
    const [name, setName] = useState('');                           ------------------- 1
    const handleNameChange = (event) => {
        setName(event.target.value);
    };
    const handleSubmit = () => {
        console.log(name);                                          ------------------- 4
    }

    return (
        <div>
            <input type="text" value={name} onChange={handleNameChange} />    --------- 2, 3
            <button onClick={handleSubmit} >Submit</button>
        </div>
    );
}

Controlled Component では react の state でユーザの入力している値を管理する

  1. 入力値用の state を作成
  2. value={name}
    • state とフォームの入力値 value を同期
  3. onChange={handleNameChange}
    • ユーザが入力を変更するたびに handleNameChange を実行
      • state をユーザの入力値に同期する
  4. 入力値が必要なタイミングで state から取得する

Controlled Component は input の change イベントで state 更新, value に state を入れるのが基本になる

Controlled Component の state 更新の流れ

画像が 'dog' とユーザが入力する場合の流れになる

最初 state の値は ’’ 空で作成. 初期化される
ユーザが 'd' を入力すると change イベントが走り handleNameChange によって state が更新される
state の更新によって react が再レンダーを行う
次に 'o' を入力したタイミングも同じ作業を繰り返し再レンダー, 'g' を入力しタイミングも同様

というように state が更新されていく
言い換えると state と入力値は常に同期されているということであり, 入力値が変わるたびに再レンダーされるということである

Controlled Component のメリット, デメリット

  • メリット
    • 常に入力値を state に持っているということ
      • state が SSOT (Single Source of Truth) になり, state のみ信用してかける
      • 入力するごとに操作 (validation, error 表示等)をしやすい
  • デメリット
    • 入力するたびに render されるので重い
      • 特にコンポーネントツリーの上位にあると下位コンポーネントも再レンダーしてしまう

まとめ

  • Controlled Component, Uncontrolled Component ともに一長一短である

    • Controlled Component のほうが柔軟にできるがパフォーマンスが低い
    • Uncontrolled Componentr のほうができることは少ないがパフォーマンスが高い
  • 考えることとして

    • Controlled のほうが React 的にかける (state で管理)
    • パフォーマンスを考えると...
      • 基本はパフォーマンスが高くシンプルにかける Uncontrolled で実装
      • 複雑なフォームは Controlled で書くということも
        e.g. 入力毎に validation, 入力値を強制する, input 同士で依存関係等

React で実際にフォームを書く

  • 下のようなフォームを実装してみる
    • input は2つ, Name と Email
      • それぞれに入力必須の validation, 入力がない場合にエラーメッセージを表示する
    • Submit ボタンがあり押すと console に入力値を表示する

コードに起こす
(以下で解説するのでそんなに見なくて ok です)

const Form = () => {
    const [values, setValues] = useState({
        name: '',
        email: ''
    });

    const [errors, setErrors] = useState({
        name: '',
        email: ''
    })

    const handleChange: (name) => (event) => void = (name) => (event) => {
        const newValues = {
            ...values,
            [name]: event.target.value
        }
        setValues(newValues);
        validate(newValues, name);
    };

    const validate = (values, name) => {
        switch(name) {
            case 'name':
                nameValidation(values.name);
                break;
            case 'email':
                emailValidation(values.email);
                break;
        }
    }

    const nameValidation = (value: string): void => {
        if (value.length === 0) {
            setErrors({...errors, name: '名前は1文字以上'});
        }
        else {
            setErrors({...errors, name: ''});
        }
    }

    const emailValidation = (value: string): void => {
        if (value.length === 0) {
            setErrors({...errors, email: 'Emailは1文字以上'});
        }
        else {
            setErrors({...errors, email: ''});
        }
    }

    const handleSubmit = () => {
        console.log(values);
    }

    return (
        <>
            <div>
                <h4>Name: </h4>
                <input type="text" name="name" value={values.name} onChange={handleChange('name')} />
                { errors.name && <span className="text-danger">{ errors.name }</span>}
                <h4>Email: </h4>
                <input type="email" name="email" value={values.email} onChange={handleChange('email')} />
                { errors.email && <span className="text-danger">{ errors.email }</span>}
                <div>
                    <button onClick={handleSubmit} >Submit</button>
                </div>
            </div>
        </>
    );
}

簡單なフォームだが随分コードが長くなってしまう

  • コードとしては
    • state が2つ
      • 入力値
        • name, email を持つオブジェクト
      • エラーメッセージ
        • name, email を持つオブジェクト
    • 入力値 state 更新メソッド
    • エラーメッセージ state 更新メソッド
    • input ごとに validate 振り分けメソッド
    • それぞれの validation メソッド

というように結構考えることが多い
フィールドが増えるとそのたびに state, メソッド等書き換える箇所が多いので考慮漏れとか起こりそう

結局素の React だけでフォームを書くのは割と大変ということに
特に GROWI.cloud では GROWI を立ち上げる際の入力するパラメータが多く, 依存関係もある
そこで React Hook Form を導入してみる

React Hook Form

React Hook Form

v7.13.0 で説明します

どういうライブラリ

React 用フォームライブラリ

特徴は

  • 記述が少なく, シンプル
    • 特にライブラリに寄せた書き方をするとかなりシンプルにかける
  • レンダリングが少なく, マウントが早い
    • Uncontrolled Component をベースに作られている
    • 他のフォームライブラリと比べてかなり早い
  • パッケージが小さく軽量
    • 依存パッケージ 0
  • リリースがかなり頻繁にされている

導入

  • yarn add react-hook-form
    • yarn add 打つだけ

コードから使い方を見る

以下のさっきと同じフォームを react-hook-form で書いてみる

const Form = () => {
  const { register, handleSubmit, formState: { errors } } = useForm();           -------------- 1
  const onSubmit = data => { console.log(data) }                                 -------------- 3

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
        <h4>Name: </h4>
        <input {...register('name', { required: true })} />                      -------------- 2
        { errors.name && <span className="text-danger">名前は1文字以上</span> }    ---------------4
        <h4>Email: </h4>
        <input { ...register('email', { required: true })} />                     -------------- 2
        { errors.email && <span className="text-danger">Emailは1文字以上</span> }  -------------- 4
        <div>
            <input type="submit" value="Submit"></input>
        </div>
    </form>
  )
}

まずコードがかなり簡素化されたのがわかる
state の管理がなくなっている

react-hook-form の基本的な使い方をコードから見る

  1. react-hook-form の提供する userForm() から必要なメソッド, オブジェクト (後述) を取得
  2. input タグへ取得した register を登録
    • register に ref が入っているので input を監視できる
    • register の引数に監視用の name , validation のルールを登録
  3. Submit ボタンを押した際の挙動を登録
  4. validation にエラーがあると useForm() から取得している errors に入ってくるので, ある場合はエラー表示

基本的には, useForm() から register とその他必要なメソッド, オブジェクトを取得
react-hook-form で監視する input へ register を登録
useForm() のメソッド, オブジェクトをよしなに使う
という流れになる

FormBuilder

react-hook-form では公式に form を簡単につくれる FormBilder というものが提供されているのでついでに紹介

https://react-hook-form.com/form-builder

ブラウザ上でフォームのレイアウトや基本的な validation を指定することでそれにあったコードが生成される

useForm()

基本的にはドキュメントにかいてある

useForm() は引数を取ることができて, 返すメソッド, オブジェクトも便利なものが多いのでいくつか紹介
以下は紹介するものを記述したもの

useForm({
    mode: 'onChange',
    defaultValues: {
        name: 'defaultName',
        email: 'defaultEmail',
    },
    shouldUnregister: false
})
  • 引数
    • mode (一部)
      • onChange
        • ユーザの入力毎に register で登録した validation を実行する
        • 内部で state を使わないので validation の結果が変わるまで再 render されない
      • onSubmit
        • submit 時に validation を実行
    • defaultValues
      • register 時に登録した name で各 input の値を一元管理ができる
      • 最初に render したときの値なので再度 defaultValues に戻したい場合等は別途に resetAPI(後述) を叩く必要がある
    • shouldUnregister
      • input がコンポーネントから消えたタイミングで unregister するかどうか
      • unregister すると input の状態(入力値等) が再度 input が render されたときに引き継がれない
  • メソッド, オブジェクト
    • register
      • input へ登録するもの
      • Reference と validation ruleの登録, エラーメッセージを登録することもできる
      • validation には HTML 標準の rule, 自前のメソッドも登録できる
    • errors
      • register 時に登録した validation にエラーがあるとこのオブジェクトに error の input が入る
    • reset
      • フォームの値を defaultValues に戻す
      • オブジェクトを引数にわたすことで values を指定することもできる
    • watch
      • 指定した input/inputs を監視する
      • readonly な入力値の state みたいなものでこれを使う場合再レンダーされる
    • getValues
      • フォームの値を取得
    • trigger
      • 手動 validation 実行メソッド
    • formState
      • isDirty, isValid, isSubmitted 等のフォームの状態を持つ

実際の GROWI.cloud での使い方

基本的な使い方は前述の通り, 実際に GROWI.cloud で使っていて, 少し困った場面やその対処について

ネストしたフォームコンポーネント

ネストしたコンポーネントとは

Input がコンポーネントの中にいてネストしている場合

例えば以下のような構成

<form onSubmit={handleSubmit(onSubmit)}>
  <NestedInputA><NestedInputA />
  <NestedInputB><NestedInputB />
  <input type="submit" value="Submit"></input>
</form>

NestedInputA/B はそれぞれ input タグを内部に持っている
useForm() から取得したものを下位の input へ渡す必要がある
単純な実装だと以下になる

<form onSubmit={handleSubmit(onSubmit)}>
  <NestedInputA register={register} errors={errors}></NestedFormA>
  <NestedInputB register={register} errors={errors}></NestedFormB>
  <input type="submit" value="Submit"></input>
</form>

useForm() から取得したものを props として下位のコンポーネントに渡している

  • しかしこれだと冗長さがあって困る
    • useForm() のオブジェクトを毎回渡さないといけない
    • input が子より下にあった場合さらにその子に渡したりと良くないバケツリレー

useFormContext()

  • useFormContext() を使うことで解決
    • react hook の useContext() のようなものが作れる
    • ネストしたツリーに context を作れて, 子から直接 useForm() を取得する事ができる

以下はそのコード

  • const Parent = () => {
    const methods = useForm();
    return (
    <FormProvider {...methods}>
        <form onSubmit={methods.handleSubmit(onSubmit)}>
            <NestedInputA></NestedInputA>
            <NestedInputB></NestedInputB>
        </form>
    </FormProvider>)
    }
  • const NestedChild = () => {
    const { register, formState: { errors }} = useFormContext();
    return (
    <>
        <input {...register('name', { required: true })} />
        {errors.name && <span className="text-danger">validation error</span>}
    </>
    }

簡単な解説

    • <FormProvider> で囲うことで context を作る
    • <FormProvider> には useForm() から取得したメソッド, オブジェクトを渡す
    • 今まで useForm() から取得していたものを同じような方法で useFormContext() から取得する
    • 同じように子供では使える

うれしいところ

  • useFormContext() を使うことで親のコンポーネントで input を管理できるようになった
    • defaultValues 等も useFormContext() へ渡せる
    • useForm() のバケツリレーをしなくて良くなった

これでも少し困ったことが

input への register する name が被るという問題
name によって input を区別しているので上手く動かない

  • 以下のような構成で input の name がかぶってしまう
    • 例えば同じような input をコンポーネントとして共通化した場合などに起こる
const Parent = () => {
  const methods = useForm();
  return (
  <>
    <FormProvider {...methods1}>
      <ChildInput></ChildInput>
      <ChildInput></ChildInput>
    </FormProvider>
  </>
  )}
  • なぜ起こるか
    • 子供で直接 input の name をしているから

以下のコードのように対処している

const Parent = () => {
  const inputName1 = ‘name’;
  const inputName2 = ‘email’;
  const methods = useForm();
  return (
  <>
    <FormProvider {...methods1}>
      <ChildInput inputName={inputName1}></ChildInput>
      <ChildInput inputName={inputName2}></ChildInput>
    </FormProvider>
  </>
  )}

name を親で作成し, 子に渡してあげることで対処している

サーバサイド validation

form の validation をサーバサイドにある API を叩くことで行いたい

サーバサイド validation の書き方

  • async 関数自体は普通にかける

    • <input {...register('name', { validate: callValidateAPI})} />
  • しかし useForm() で mode: onChange を指定したときに少し嫌なことが

    • ユーザが入力するたびに validation API を叩くことになるので, サーバに余分な負荷がかかる
      • ユーザが入力を止めたタイミングで送るくらいで良くて, 実際にはそこまで毎回送らなくても良い

debounce の導入をした

debounce 処理自体はライブラリ使用

debounceとは
  • ある関数を指定時間中, 最後の1回だけ実行する
    • 関数の実行を間引くことができる
import callValidateAPI from './callValidateAPI';

const Input = () => {
    conse { register } = useFormContext();
    const debouncedValidate = debounce(200, callValidateAPI);
    return (
        <input {...register('props.inputName', { validate: debouncedValidate })} />
    );
}

上記のようなコードで200ms に複数回送らないように間引くことができる

再レンダリングされる場合

  • ただし以下のようなフォームで少し問題が
    • 名前を入力すると現在の入力値が下にでるようなフォーム

下に表示するものを useForm() の watch によって実現している

  • watch を使うことによってコンポーネントが再レンダリングされる
    • debounce 関数が再レンダリングによって別のオブジェクトとして扱われる
      • state, watch 等で管理すると debounce が思うように動かない

以下解決しているコード

import callValidateAPI from './callValidateAPI';

const Input = () => {
    conse { register } = useFormContext();
    const debouncedValidate = React.useCallback(debounce(200, callValidateAPI), []);
    return (
        <input {...register('props.inputName', { validate: debouncedValidate })} />
    );
}

useCallback に debounce している関数を渡し, 固定することで(暫定に)対処している

外部ライブラリとの連携

  • react-hook-form で管理する input に外部の UI ライブラリ等を使いたい場合がある
    • 外部のライブラリが uncontrolled でない場合等は ref に登録できないので今まで説明した方法ではできない

controller

react-hook-form の提供する controller を使うことで可能

以下のようなコードになる

<Controller
    name="name"
    control={control}
    rules={required: true}
    render={({onChange, onBlur, value}) => {
        <CustomInput
            value={value}
            onBlur={onBlur}
            onChange={onChange}
        />
    }}
>
</Controller>
  • useForm() から受け取る control を登録することで react-hook-form による管理
  • rules には register に渡す validation と同様の方法で記述

react-hook-form のテスト

https://react-hook-form.com/advanced-usage#TestingForm

We recommend using testing-library, because it is simple and tests are more focused on user behavior.

react-hook-form では testing-library を推奨しているので軽く紹介

基本的には testing-library に則って行う

  • 注意点
    • handleSubmit が非同期なので Submit 後のテストに WaitFor, FindBy を使用

公式から抜粋

  • テスト対象コンポーネント
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>
  );
}
  • テストコードと説明 (細かいとこは省いている)

    const mockLogin = jest.fn((email, password) => {
    return Promise.resolve({ email, password });
    });
    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();
    });
  • 何もせず submitEvent を発火したときに required に引っかかるテスト

    • getByRole("button") で submit ボタンを取得し, submit イベントを発火
    • role="alert"である error message が2つあることをテスト
    • handleSubmit により invalid 時に呼ばれていないことをテスト

このようにほぼほぼ testing-library で実現

  • 次のテストコード (mockLogin は上と同じ)
 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");
  });
  • 同様に role から input を取得し, fireEvent によって値を書き換える
  • 今回 email の値が invalid なので error message 1つでることを確認している

まとめ

  • (特に複雑な)フォームの実装は設計をしっかりする必要がある

    • フォームは考えることも多く, ユーザと対話するものなので重要である
  • React でのフォームには Uncontrolled Component, Controlled Component がある

    • それぞれ一長一短であった
  • ライブラリの便利さ

    • react-hook-form を入れることでかなり記述量が減った

著者プロフィール

藤澤 拓也

株式会社WESEEK / システムエンジニア

大学で Computer Science を軽く学ぶ. 大学在学時に WESEEK でインターンを開始し, そのまま入社.
インターン開始と同時に Web 開発に初めて携わる.

最初は主に Angular を触って, 最近では React, Rails, Kubernetes 等いろいろ手を付けようとしています

株式会社WESEEKについて

株式会社WESEEKは、システム開発のプロフェッショナル集団です。

【現在の主な事業】

  1. 通信大手企業の業務フロー自動化プロジェクト
  2. ソーシャルゲームの受託開発
  3. 自社発オープンソースプロダクト「GROWI」「GROWI.cloud」の開発

GROWI

GROWIは、Markdown記法でページを記述できるオープンソースのWikiシステムです。

GROWI.cloud

GROWI.cloudはOSSのGROWIを専門的知識がなくても簡単に運用・管理できる、法人・個人向けの商用サービスです。

大手SIer・ISPや中小企業、大学の研究室など様々な場所でご利用いただいております。

【主な特徴】

  • テキストも図表もどんどん書ける、強力な編集機能
  • チーム拡大に迅速に対応できる管理者向け機能を提供
  • 充実した機能・サポートでエンタープライズにも対応

【導入事例記事】

インターネットマルチフィード株式会社様

株式会社HIKKY(VR法人HIKKY)様

WESEEK Tech Conference

WESEEK Tech Conferenceは、株式会社WESEEKが主催するエンジニア向けの勉強会です。
月に2回ほど、WESEEKに所属するエンジニアが様々なテーマで発表を行う予定です。

次回は、9/16(木) 19:00~20:00『既存RailsアプリをSSO化して、本番環境で活用した話』

既存の Rails アプリを OpenID Connect に対応させ、弊社と取引があるインターネットマルチフィード様のポータルサイトに導入するまでの経緯を紹介します。
また、OpenID Connect 基盤を活用した複数のバックエンドやサイトで認証、認可を統一した事例もあわせてお話しします。

現在、connpassやTECH PLAYで参加受付中です。皆様のご参加をお待ちしております!
https://weseek.connpass.com/event/223217/

TECH PLAYはこちらから

一緒に働く仲間を募集しています

東京の高田馬場オフィス、大分にある別府サテライトオフィスにてエンジニアを募集しております。

中途採用だけではなく、インターンシップも積極的に受け入れています!

詳しい募集要項は、弊社HPの採用ページからご確認ください。

関連記事

React Uncontrolled

React Hook Form