Reactで1文字ごとに入力欄のフォーカスが外れる原因と直し方

​​React​ESLint,TypeScript,useState,フォーム

React のフォームで、入力欄に1文字打つとフォーカスが外れ、2文字目から入力できなくなることがあります。親のコンポーネントの中で、入力欄のコンポーネントを定義していると起きます。入力欄のコンポーネントをファイルの一番外側に出し、値と変更の関数を props で渡すと直ります。

検索フォームや問い合わせフォームで、親の state を使いたくて入力欄を親の中で部品にすると、この書き方になります。私は検証環境で、中で定義した場合と、直したあとの書き方を並べて、キーボードで「neko」と打ったときの動きを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、react / react-dom 19.3.1、TypeScript 6.0.3 です。.tsx を tsc で型チェックしてから esbuild で開発用にまとめ、Playwright 1.63.0 で動かした Chromium 153 で入力しました。マウントのやり直しが記録に混ざらないよう、StrictMode は使っていません。Vite や Next.js の上での動作、Chromium 以外のブラウザ、React Compiler を有効にしたビルドでの動作は試していません。

中で定義した入力欄は、1文字目で止まる

次の NestedForm は、名前を state に持ち、その state を使う入力欄を NameField という部品にしています。NameField は NestedForm の関数の中で定義しています。useEffect の部分は、マウントとアンマウントを記録するための検証用のコードです。この記事のコードでは、import の行を省いています。

tsx
export function NestedForm() {
  const [name, setName] = useState('');

  function NameField() {
    useEffect(() => {
      log('マウント(NameField)');
      return () => log('アンマウント(NameField)');
    }, []);
    return <input value={name} onChange={(e) => setName(e.target.value)} />;
  }

  return (
    <div>
      <NameField />
      <p>入力した名前:{name}</p>
    </div>
  );
}

入力欄をクリックしてから、キーボードで「n」「e」「k」「o」と1文字ずつ打ちました。各行の「フォーカス」は、そのときフォーカスがある要素です。「最初と同じ input 要素か」は、打つ前に入力欄の要素へ目印の属性を書き込んでおき、その目印が残っているかで調べました。

text
--- NestedForm ---
  [マウント(NameField)]
  > 入力欄をクリックして、キーボードで "neko" と打つ(1文字ずつ)
  [アンマウント(NameField)]
  [マウント(NameField)]
  「n」のあと: 入力欄="n" / 入力した名前:n / フォーカス=body / 最初と同じ input 要素か=false
  「e」のあと: 入力欄="n" / 入力した名前:n / フォーカス=body / 最初と同じ input 要素か=false
  「k」のあと: 入力欄="n" / 入力した名前:n / フォーカス=body / 最初と同じ input 要素か=false
  「o」のあと: 入力欄="n" / 入力した名前:n / フォーカス=body / 最初と同じ input 要素か=false

1文字目の「n」は入りましたが、その時点でフォーカスは body に移り、残りの3文字はどこにも入りませんでした。入力欄の要素も、最初にクリックしたものとは別の要素に変わっています。

1文字ごとに入力欄をクリックし直すと、4文字とも入りました。そのかわり、1文字打つたびにアンマウントとマウントが記録され、フォーカスは毎回 body に戻りました。次の出力は、最初の2文字の部分だけを抜き出したものです。

text
  「n」のあと: 入力欄="n" / フォーカス=body
  [アンマウント(NameField)]
  [マウント(NameField)]
  「e」のあと: 入力欄="ne" / フォーカス=body

次の GIF は、上に中で定義した NameField、下に外に出した NameField(直し方は後の節で説明します)を並べ、それぞれの入力欄で「neko」と打ったところです。

上下2つの入力欄に順に neko と打つ様子。上の中で定義した入力欄は n を打った時点でフォーカスがなくなり n のまま止まる。下の外に出した入力欄はフォーカスが残り neko まで入る
中で定義した入力欄(上)と外に出した入力欄(下)に、キーボードで1文字ずつ打った様子

描画のたびに別のコンポーネントとして作り直される

1文字打つと setName が呼ばれ、NestedForm がもう一度描画されます。このとき function NameField() { … } もあらためて実行されるので、前回とは別の関数ができます。

React は、同じ位置に描画されたものが同じ種類かどうかを、コンポーネントの関数そのもので比べます。関数が別なら、別の種類のコンポーネントとみなし、前の NameField をアンマウントして、新しい NameField をマウントします。公式ドキュメントの、同じ位置で種類が変わると state がリセットされる節では、この書き方を例に挙げ、コンポーネントの関数は一番外側で定義し、入れ子にしないよう勧めています。

アンマウントのときには、state だけでなく DOM の要素も取り除かれます(ESLint のルール static-components の説明)。フォーカスがあった input 要素がページから消え、新しく作られた input にはフォーカスがないので、続けて打った文字はどこにも入りません。検証で body にフォーカスが移り、目印のない input に変わっていたのは、このためです。

外に出して、値と変更の関数を props で渡す

NameField をファイルの一番外側に出します。親の state はそのままでは読めないので、値と、変更のときに呼ぶ関数を props で受け取ります。

tsx
type NameFieldProps = {
  value: string;
  onChange: (e: ChangeEvent<HTMLInputElement>) => void;
};

function NameField({ value, onChange }: NameFieldProps) {
  // (マウントを記録する useEffect は省略)
  return <input value={value} onChange={onChange} />;
}

export function TopLevelForm() {
  const [name, setName] = useState('');
  return (
    <div>
      <NameField value={name} onChange={(e) => setName(e.target.value)} />
      <p>入力した名前:{name}</p>
    </div>
  );
}
text
--- TopLevelForm ---
  [マウント(NameField)]
  > 入力欄をクリックして、キーボードで "neko" と打つ(1文字ずつ)
  「n」のあと: 入力欄="n" / 入力した名前:n / フォーカス=input / 最初と同じ input 要素か=true
  「e」のあと: 入力欄="ne" / 入力した名前:ne / フォーカス=input / 最初と同じ input 要素か=true
  「k」のあと: 入力欄="nek" / 入力した名前:nek / フォーカス=input / 最初と同じ input 要素か=true
  「o」のあと: 入力欄="neko" / 入力した名前:neko / フォーカス=input / 最初と同じ input 要素か=true

マウントは最初の1回だけで、4文字とも入りました。NameField は一度だけ定義された同じ関数なので、TopLevelForm が描画し直されても同じ種類のままです。input 要素は作り直されず、そのままの要素で value が書き換わります。

ポイント

コンポーネントの中から親の state を使いたくなったら、中で定義するのではなく、props で渡します。公式ドキュメントでも、中で定義したくなるのは props で渡すべきという合図だ、と説明しています(static-components の説明にある、親の state を使いたいときの注記)。

小さな部品なら、JSX を変数に入れるだけでもよい

1か所でしか使わない入力欄なら、コンポーネントにせず、JSX を変数に入れておく書き方でも直ります。

tsx
export function JsxVariableForm() {
  const [name, setName] = useState('');
  const nameField = <input value={name} onChange={(e) => setName(e.target.value)} />;
  return (
    <div>
      {nameField}
      <p>入力した名前:{name}</p>
    </div>
  );
}
text
--- JsxVariableForm ---
  > 入力欄をクリックして、キーボードで "neko" と打つ(1文字ずつ)
  「n」のあと: 入力欄="n" / 入力した名前:n / フォーカス=input / 最初と同じ input 要素か=true
  「e」のあと: 入力欄="ne" / 入力した名前:ne / フォーカス=input / 最初と同じ input 要素か=true
  「k」のあと: 入力欄="nek" / 入力した名前:nek / フォーカス=input / 最初と同じ input 要素か=true
  「o」のあと: 入力欄="neko" / 入力した名前:neko / フォーカス=input / 最初と同じ input 要素か=true

nameField は関数ではなく、描画のたびに作られる <input> の JSX です。React から見ると、同じ位置にいつも input という同じ種類の要素があるので、要素は作り直されません。

useCallback で包んでも直らない

関数が毎回変わるのが原因なら useCallback で同じ関数を使い回せばよい、と考えるかもしれません。入力欄の中で name を読むので、依存配列には name が入ります。

tsx
export function UseCallbackForm() {
  const [name, setName] = useState('');

  const NameField = useCallback(() => {
    return <input value={name} onChange={(e) => setName(e.target.value)} />;
  }, [name]);

  return (
    <div>
      <NameField />
      <p>入力した名前:{name}</p>
    </div>
  );
}
text
--- UseCallbackForm ---
  > 入力欄をクリックして、キーボードで "neko" と打つ(1文字ずつ)
  「n」のあと: 入力欄="n" / 入力した名前:n / フォーカス=body / 最初と同じ input 要素か=false
  「e」のあと: 入力欄="n" / 入力した名前:n / フォーカス=body / 最初と同じ input 要素か=false
  「k」のあと: 入力欄="n" / 入力した名前:n / フォーカス=body / 最初と同じ input 要素か=false
  「o」のあと: 入力欄="n" / 入力した名前:n / フォーカス=body / 最初と同じ input 要素か=false

結果は中で定義したときと同じでした。useCallback のリファレンスにあるとおり、依存配列の値が前回から変わると、useCallback はその描画で渡された新しい関数を返します。1文字打つと name が変わるので、NameField はやはり別の種類のコンポーネントになります。

ESLint の static-components で見つける

eslint-plugin-react-hooks には、コンポーネントの中でコンポーネントを作る書き方を見つける static-components というルールがあり、recommended の設定に入っています。検証で使った 7.1.1 では、次の設定で .tsx を調べました。

js
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';

export default [
  { files: ['src/**/*.tsx'], languageOptions: { parser: tseslint.parser } },
  reactHooks.configs.flat.recommended,
];

この記事の4つの書き方を1つのファイルに入れて実行すると、中で定義した NestedForm と、useCallback で包んだ UseCallbackForm の2か所がエラーになりました。外に出した書き方と、JSX を変数に入れた書き方はエラーになっていません。出力の作業ディレクトリのパスは /path/to/app に置き換え、コードの抜き出し部分は一部省略しています。

text
/path/to/app/src/cases.tsx
  19:8  error  Error: Cannot create components during render

Components created during render will reset their state each time they are created. Declare components outside of render.
(中略)
  71:8  error  Error: Cannot create components during render
(中略)
✖ 2 problems (2 errors, 0 warnings)

メッセージは「描画中にコンポーネントを作れない。作られるたびに state がリセットされるので、描画の外で宣言する」という意味です。入力欄のフォーカスが外れる不具合は画面で触らないと気づきにくいので、このルールを有効にしておくと、書いた時点で見つけられます。

​Posted by 86