Udemyのオススメ講座はこちら 詳細を見てみる

【React】React-Hook-Formでバリデーション時のフォーカスを自作する

  • URLをコピーしました!

Reactでのフロント開発において、入力フォーム等のフロントエンドでもバリデーションを気軽に実装することができるライブラリであるReact-Hook-Formを使っている中でどうしてもライブラリの標準機能で対応できないことがあったのでそれについての解決法をまとめます。

React-Hook-Formの基本的な使い方は別の記事にまとめています。

目次

解決したい問題

解決したい問題の前に前提条件は以下です。

  • 1つの画面に入力フォームA、入力フォームB、入力フォームCの計3の入力フォーム(inputタグのtype='text')があり、それは1つのフォーム(formタグ)の括りとする
  • 各入力フォームのコーディング順、配置順共にA→B→C
  • 入力フォームBはデフォルトでは非表示、何かしらのインプット値によって表示と非表示を切り替える
  • 3つの入力フォーム全て必須入力のバリデーションをReact-Hook-Formで設定している(required
  • shouldFocusError: trueを設定してバリデーションエラー時にはエラーがある要素の1つ目にフォーカスする
対象の入力フォームのイメージ

この前提で発生する問題はここからで、以下の状態で送信ボタンを押したとします。

  • 入力フォームAは文字列を入力
  • 入力フォームBは何も入力しない
  • 入力フォームCは何も入力しない

これ、正しい(というか期待する)挙動は「入力フォームBにフォーカスされる」ですが、実際には入力フォームCにフォーカスが当たります

この記事をこの問題の解決までのプロセスも含めてまとめます。

原因の解明

色々調べたのですが、これを解決できる術を思いつかなかったので、ツイートしました(笑)

ちなみにReact-Hook-Formはドキュメントがとても豊富なので、調べる対象はドキュメントだけで十分だと

すると、公式アカウントからリプライが…!

ということでReact-Hook-FormのGitHubでDiscussionを立てて質問しました。(DeepLフル活用です)

詳細が気になる方は上のDiscussionを見に行って欲しいのですが、要約すると

  • 原因はregisterは画面に表示された順番で登録するので、画面上の入力フォームの配置順はA→B→Cになるけど、今回のケースだと画面にレンダリングされた順がA→C→BになるのでBとCでバリデーションエラーがあった場合はCが先に検出されフォーカス対象はCになってしまう。
  • 現在のReact-Hook-Formにはregisterでの登録順を調整する機能がない。(実装が大変なんだ、みたいなコメントもありました)

という感じでした。

余談ですがDiscussionを立てたらすぐに返信が来たのでびっくりしたのとめっちゃありがたかったです。

原因がわかったのでかなりスッキリしたのですが、さてどうしようとなりました。

バリデーション時のスクロールを自作する

Discussionに以下の返信をもらいました。

yes, register invoke order does matter. for custom behavior, I would recommend turning off shouldFocus and using setFocus to manually set field focus at the app level with custom logic.

https://github.com/react-hook-form/react-hook-form/discussions/7739#discussioncomment-2107024

簡潔に訳すと

shouldFocusErrorを使ったらできないからfalseにして、setFocusというプロパティを使ってフォーカスのロジックを自作したらできるで」

という感じです。

というわけでサンプルコードですが、自作しました。

import { ErrorMessage } from "@hookform/error-message";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import "./styles.css";

type Inputs = {
  formA: string;
  formB: string;
  formC: string;
};

export default function App() {
  const [isDisplay, setIsDisplay] = useState(false);
  const {
    register,
    handleSubmit,
    setFocus,
    formState: { errors, submitCount }
  } = useForm<Inputs>({
    shouldFocusError: false
  });

  useEffect(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
    errors && errors.formA
      ? setFocus("formA")
      : errors.formB
      ? setFocus("formB")
      : errors.formC
      ? setFocus("formC")
      : null;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [submitCount]);

  const submitForSample = () => alert("送信成功");
  return (
    <div>
      <button onClick={() => setIsDisplay(!isDisplay)}>
        入力フォームBの表示/切り替えボタン
      </button>
      <br />
      <br />
      <form action="" onSubmit={handleSubmit(submitForSample)}>
        <div>
          入力フォームA:
          <input
            type="text"
            {...register("formA", { required: "必須入力です。" })}
          />
        </div>
        <ErrorMessage
          errors={errors}
          name={"formA"}
          render={({ message }) => <p style={{ color: "red" }}>{message}</p>}
        />
        {isDisplay && (
          <>
            <div>
              入力フォームB:
              <input
                type="text"
                {...register("formB", { required: "必須入力です。" })}
              />
            </div>
            <ErrorMessage
              errors={errors}
              name={"formB"}
              render={({ message }) => (
                <p style={{ color: "red" }}>{message}</p>
              )}
            />
          </>
        )}
        <div>
          入力フォームC:
          <input
            type="text"
            {...register("formC", { required: "必須入力です。" })}
          />
        </div>
        <ErrorMessage
          errors={errors}
          name={"formC"}
          render={({ message }) => <p style={{ color: "red" }}>{message}</p>}
        />
        <br />
        <div>
          <input type="submit" value="送信" />
        </div>
      </form>
    </div>
  );
}

少しだけコードを解説をすると、まずはReact-Hook-Formから必要なプロパティを変数に入れ、バリデーションエラー時に1番最初の入力フォームにフォーカスするshouldFocusErrorfalseにします。

  const {
    register,
    handleSubmit,
    setFocus,
    formState: { errors, submitCount }
  } = useForm<Inputs>({
    shouldFocusError: false
  });

各プロパティの簡単な役割は以下です。

  • register:バリデーションを適用するパラメーター名(今回でいうformA, formB, formC)
  • handleSubmit:引数に渡した処理を実行するまでにRact-Hook-Formでバリデーションをかける
  • setFocus:引数に渡したregisterで登録したパラメーター名にフォーカスする
  • errors:バリデーション情報
  • submitCount:handleSubmitが実行された回数(バリデーション検証回数)

自作したスクロールのロジックはuseEffect内に記載しています。

CodeSandboxの設定でESLintのWarningが出るのでコメントで無効化しています。

 useEffect(() => {
    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
    errors && errors.formA
      ? setFocus("formA")
      : errors.formB
      ? setFocus("formB")
      : errors.formC
      ? setFocus("formC")
      : null;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [submitCount]);

依存配列にsubmitCountを設定することで、送信ボタンを押した段階でuseEffec内の処理を実行するようにし、バリデーション情報のうち、registerで登録したパラメータのエラーがあればそこにフォーカスするような処理にしています。

これで、以下のように入力フォームBを非表示→表示にした状態でも入力Bにエラーがあればフォーカスできるようになります。

CodeSandboxも用意していますので実際に動かすこともできます。

最後に

ライブラリ側でregisterの登録順序を変更する仕様はまだまだ実装されなさそう(今後ずっとされないかも)なので自作でもできることがわかったのが大きな収穫でした。

また、管理者がしっかりしている(?)ライブラリでは公式のGitHubで質問することでかなり解決に近づけることがあり、今後に活かせる経験でした。

React-Hook-Formの基本的な使い方はこちらにまとめています。

Udemyのオススメ講座はこちら↓

TypeScript+Next.js+Laravelハンズオンはこちら↓

デスク周りのオススメアイテムはこちら↓

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

ブログを開設するなら「SWELL」が絶対オススメ!

この記事を書いた人

大学院(機械工学)→重工業→エンジニア→プロダクトマネージャー(PdM)兼エンジニア

神戸で「つながる勉強会」を運営↓
https://tsunagaru-kobe.connpass.com/

神戸グルメのインスタアカウントを運用しています。

目次