グローバルナビゲーションへ

本文へ

フッターへ

お役立ち情報Blog



Reactでフォーカス可能な要素にフォーカスを当てる方法

入力要素にフォーカスしたい

編集ボタンを押すと入力要素を表示するフォームを無性に作りたくなる時ってありますよね。 例えばこんな感じのフォームです。

キーボード操作の時に使いづらい

筆者はキーボード操作でフォームの入力を行うことが多いのですが、この場合キーボード操作だけだと使いづらいと感じます。

キーボード操作で使いづらいと感じるのは、編集ボタンを押した後にShift + Tabで入力要素にフォーカスを当てなければいけないことです。

フォーカスが入力要素に当たっていないので編集ボタン押下後に文字を入力しても入力要素に反映されません。

では入力要素にフォーカスを当てることにしましょう。

ここまでのコード

import { useState } from "react";
import { useForm } from "react-hook-form";

import type { FC } from "react";

type InputForm = {
  readonly email: string;
};

type InputFormProps = InputForm & {
  readonly setState: (args: { email: string }) => void;
};

const InputForm: FC<InputFormProps> = ({ email, setState }) => {
  const [isOpen, setIsOpen] = useState(false);
  const close = () => setIsOpen(false);
  const toggle = () => setIsOpen((prev) => !prev);
  const {
    handleSubmit,
    register,
    formState: { isSubmitting },
  } = useForm<InputForm>({
    defaultValues: {
      email,
    },
  });

  return (
    <form
      onSubmit={handleSubmit(async (input) => {
        // APIコールを擬似的にPromiseで再現、実際にはWebAPIに対してリクエストを送ることが多いです
        await new Promise((r) => setTimeout(r, 3000));
        setState(input);
        close();
      })}
    >
      <fieldset disabled={isSubmitting}>
        <div>
          <div>
            <label htmlFor="email">メールアドレス</label>
            {isOpen ? (
              <input id="email" type="text" {...register("email")} />
            ) : (
              <span>{email}</span>
            )}
          </div>
        </div>
        <div>
          <div>
              {isOpen ? "キャンセル" : "編集"}
            </button>
            {isOpen && <button type="submit">送信</button>}
          </div>
        </div>
      </fieldset>
    </form>
  );
};

export const App: FC = () => {
  const [state, setState] = useState({
    email: "alice@example.com",
  });

  return <InputForm {...state} setState={setState} />;
};

※スタイリング用の CSS のクラス名は除いています

React で要素にフォーカスする方法

React で DOM 要素を操作するといえば ref です。

今回のケースであれば ref コールバック関数が使えます。

import { useState } from "react";
 import { useForm } from "react-hook-form";

 import type { FC } from "react";
@@ -27,6 +27,10 @@ const InputForm: FC<InputFormProps> = ({ email, setState }) => {
     },
   });

+  const ref = (element: HTMLInputElement | null) => {
+    element?.focus();
+  };
+
   return (
     <form
       onSubmit={handleSubmit(async (input) => {
@@ -41,7 +45,7 @@ const InputForm: FC<InputFormProps> = ({ email, setState }) => {
           <div>
             <label htmlFor="email">メールアドレス</label>
             {isOpen ? (
-              <input id="email" type="text" {...register("email")} />
+              <input id="email" type="text" {...register("email")} ref={ref} />
             ) : (
               <span>{email}</span>
             )}

ref コールバック関数を作成し入力要素の ref に渡すと、入力要素の render 時に ref コールバック関数が実行されるようになります。

実際に確認してみます。

編集ボタンを押すと入力要素にフォーカスが当たって、タブ移動をしなくてもすぐ入力が始められるようになりました。
入力要素もフォーカスが当てられることに喜びをアニメーションで表していますね。めでたしめでたし。

とはなりません。

上記の例ではフォーカスさせたい要素の数だけ ref コールバック関数を用意しなければならず再利用性が低いです。
それ以外にも上のアニメーションには続きがありまして、要素に入力して保存すると

入力した値が消えてしまいます。

これは入力要素の input タグに react-hook-form の register 関数を使用しており、react-hook-form の register 関数の返り値に含まれる ref を今回新たに追加した ref コールバック関数が上書きしてしまっているためです。

ここまでの問題

  • フォーカスさせたい要素の数だけ ref コールバック関数を都度用意しなければならず再利用性が低い
  • react-hook-form の register 関数を使用している場合、ref コールバック関数を使用すると上書きして react-hook-form の挙動を上書きしてしまう

やりたいこと

ここまでの問題を元にやりたいことを整理します。

  • Focusable コンポーネントの作成:再利用性を高めるために Focasable コンポーネントを作成し、ref コールバック関数の実装を Focasable コンポーネント内に隠蔽する
  • ref の合成:既に ref を使用している場合(e.g. react-hook-form の register 関数を使用している)にも対応したい

Focasable コンポーネントの作成

まずは Focusable コンポーネントを作成していきます。

Focasable コンポーネントを使用して使うコードは以下のような感じでしょうか。

<Focusable>
  <input type="text" />
</Focusable>

Focusable コンポーネントを実装していきます。

// フォーカス可能な要素のCSSセレクタです
const FOCUSABLE_CSS_SELECTOR =
  "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), *[tabindex], *[contenteditable]";

type FocusableProps = {
  readonly children: ReactNode;
};

const Focusable: FC<FocusableProps> = ({ children }) => {
  const ref = (element: HTMLElement | null) => {
    element?.focus();
    element?.querySelector<HTMLElement>(FOCUSABLE_CSS_SELECTOR)?.focus();
  };

  // ref をどうやってPropsに渡されたchildrenに渡せば...? :thinking:
  return children;
};

早速行き詰まりました。問題は props で渡された children に対してどうやって ref を適用すればいいのか。

cloneElement という黒魔術

ここで cloneElement という黒魔術をご紹介します。

落とし穴

cloneElement の使用は一般的ではなく、コードが壊れやすくなる可能性があります。一般的な代替手段をご覧ください。

cloneElement – React

公式ドキュメントにも コードが壊れやすくなる可能性があります とあるようにご使用の場合は用法・容量を守って正しくお使いください。

cloneElement をすると props で渡された ReactElement 要素を第一引数に、第二引数で渡した props や ref などを合成して新しい ReactElement を作成してくれます。

-import { useState } from "react";
+import { cloneElement, useState } from "react";
 import { useForm } from "react-hook-form";

-import type { FC, ReactNode } from "react";
+import type { FC, ReactElement } from "react";

 import "./App.css";

@@ -9,7 +9,7 @@ const FOCUSABLE_CSS_SELECTOR =
   "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), *[tabindex], *[contenteditable]";

 type FocusableProps = {
-  readonly children: ReactNode;
+  readonly children: ReactElement;
 };

 const Focusable: FC<FocusableProps> = ({ children }) => {
@@ -18,7 +18,7 @@ const Focusable: FC<FocusableProps> = ({ children }) => {
     element?.querySelector<HTMLElement>(FOCUSABLE_CSS_SELECTOR)?.focus();
   };

-  return children;
+  return cloneElement(children, { ref: ref });
 };

元のコードも ref コールバック関数を入力要素に ref を渡す形式から Focusable コンポーネントで囲うように変更します。

-  const ref = (element: HTMLInputElement | null) => {
-    element?.focus();
-  };
-
   return (
     <form
       onSubmit={handleSubmit(async (input) => {
@@ -61,7 +57,9 @@ const InputForm: FC<InputFormProps> = ({ email, setState }) => {
           <div>
             <label htmlFor="email">メールアドレス</label>
             {isOpen ? (
-              <input id="email" type="text" {...register("email")} ref={ref} />
+              <Focusable>
+                <input id="email" type="text" {...register("email")} />
+              </Focusable>
             ) : (
               <span>{email}</span>
             )}

この段階ではまだ ref の合成はできていませんが、Focusable コンポーネントで囲うだけという再利用しやすい形になったのではないでしょうか。

ref の合成

最後に react-hook-form の register 関数を使用している入力要素とも連携できるように ref を合成していきます。

ref の合成方法は GitHub 上のコードを拝借しています。 React 公式 の GitHub で複数の ref に関しての issue がありますのでご参考まで。

Share ref with multiple ref handlers · Issue #13029 · facebook/react

-import type { FC, ReactElement } from "react";
+import type { FC, MutableRefObject, ReactElement, Ref } from "react";

import "./App.css";

+function useCombinedRef<T extends HTMLElement>(
+ ...refs: readonly Ref<T>[]
+): (element: T) => void {
+ return (element: T) => {
+ refs.forEach((ref) => {
+ if (!ref) {
+ return;
+ }
+
+ if (typeof ref === "function") {
+ ref(element);
+ }
+
+ (ref as MutableRefObject<T>).current = element;
+ });
+ };
+}
+
const FOCUSABLE_CSS_SELECTOR =
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), *[tabindex], *[contenteditable]";

@@ -18,7 +36,14 @@ const Focusable: FC<FocusableProps> = ({ children }) => {
element?.querySelector<HTMLElement>(FOCUSABLE_CSS_SELECTOR)?.focus();
};

- return cloneElement(children, { ref: ref });
+ // children に ref を渡している場合は childrenの ref と フォーカス用の ref コールバック関数を合成します
+ const refs =
+ "ref" in children
+ ? [(children as { ref: Ref<HTMLElement> | null }).ref, ref]
+ : [ref];
+ const combinedRef = useCombinedRef(...refs);
+
+ return cloneElement(children, { ref: combinedRef });
};

編集ボタンを押した後に入力要素にフォーカスがあたり、入力した値が反映されていることが確認できました。

今回実装したコード

最後にここまで今回実装してきたコードを掲載します。

import { cloneElement, useState } from "react";
import { useForm } from "react-hook-form";

import type { FC, MutableRefObject, ReactElement, Ref } from "react";

import "./App.css";

function useCombinedRef<T extends HTMLElement>(
  ...refs: readonly Ref<T>[]
): (element: T) => void {
  return (element: T) => {
    refs.forEach((ref) => {
      if (!ref) {
        return;
      }

      if (typeof ref === "function") {
        ref(element);
      }

      (ref as MutableRefObject<T>).current = element;
    });
  };
}

const FOCUSABLE_CSS_SELECTOR =
  "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), *[tabindex], *[contenteditable]";

type FocusableProps = {
  readonly children: ReactElement;
};

const Focusable: FC<FocusableProps> = ({ children }) => {
  const ref = (element: HTMLElement | null) => {
    element?.focus();
    element?.querySelector<HTMLElement>(FOCUSABLE_CSS_SELECTOR)?.focus();
  };

  // children に ref を渡している場合は childrenの ref と フォーカス用の ref コールバック関数を合成します
  const refs =
    "ref" in children
      ? [(children as { ref: Ref<HTMLElement> | null }).ref, ref]
      : [ref];
  const combinedRef = useCombinedRef(...refs);

  return cloneElement(children, { ref: combinedRef });
};

type InputForm = {
  readonly email: string;
};

type InputFormProps = InputForm & {
  readonly setState: (args: { email: string }) => void;
};

const InputForm: FC<InputFormProps> = ({ email, setState }) => {
  const [isOpen, setIsOpen] = useState(false);
  const close = () => setIsOpen(false);
  const toggle = () => setIsOpen((prev) => !prev);
  const {
    handleSubmit,
    register,
    formState: { isSubmitting },
  } = useForm<InputForm>({
    defaultValues: {
      email,
    },
  });

  return (
    <form
      onSubmit={handleSubmit(async (input) => {
        // APIコールを擬似的にPromiseで再現、実際にはWebAPIに対してリクエストを送ることが多いです
        await new Promise((r) => setTimeout(r, 3000));
        setState(input);
        close();
      })}
    >
      <fieldset className="wrapper" disabled={isSubmitting}>
        <div className="row">
          <div className="field">
            <label htmlFor="email">メールアドレス</label>
            {isOpen ? (
              <Focusable>
                <input id="email" type="text" {...register("email")} />
              </Focusable>
            ) : (
              <span>{email}</span>
            )}
          </div>
        </div>
        <div className="row">
          <div className="btn-wrapper">
            <button type="button" onClick={toggle}>
              {isOpen ? "キャンセル" : "編集"}
            </button>
            {isOpen && <button type="submit">送信</button>}
          </div>
        </div>
      </fieldset>
    </form>
  );
};

export const App: FC = () => {
  const [state, setState] = useState({
    email: "alice@example.com",
  });

  return <InputForm {...state} setState={setState} />;
};

まとめ

React でフォーカス可能な要素にフォーカスを当てる方法についてご紹介しました。

筆者はキーボード操作を好むので今回のような仕様は大好物ですが、人によってはキャンセルボタンにフォーカスが当たっていて欲しい方や、フォーカス移動をシステムで強制的に行われることに嫌悪感を覚える方もいるかもしれません。

良い UX(ユーザエクスペリエンス)とはなんぞや。この世の真理を知りたい。

この記事を書いた人

美髭公
美髭公事業開発部 web application engineer
2013年にアーティスに入社。システムエンジニアとしてアーティスCMSを使用したWebサイトや受託システムの構築・保守に携わる。環境構築が好き。
この記事のカテゴリ

FOLLOW US

最新の情報をお届けします