Reactで、日本語の変換が終わってから検索を動かす方法

​​React​TypeScript,フォーム,日本語入力,検索

React の onChange は、日本語の変換中にも、文字が変わるたびに呼ばれます。入力しながら結果が絞り込まれる検索欄(インクリメンタルサーチ)や、検索候補(サジェスト)を出す入力欄で、onChange からそのまま検索を呼ぶと、「ねk」のような変換の途中の文字でも検索されます。変換中は e.nativeEvent.isComposing を見て検索を飛ばし、onCompositionEnd で検索すると、確定した文字でだけ検索されるようになります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは react / react-dom 19.3.1、@types/react / @types/react-dom 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 と、それに付属の Chromium 153 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium で動かしました。日本語入力の変換中の状態は、本物の IME ではなく、Chrome DevTools Protocol(CDP)の Input.imeSetComposition で変換中の文字を出し、Input.insertText で確定させて作っています。Firefox・Safari、スマートフォンのソフトウェアキーボード、実際の IME(Mozc など)での動きは試していません。

上に onChange のたびに検索する入力欄、下に変換が終わってから検索する入力欄を並べている。N、E、K、O と打つと、どちらの欄にも変換中の「n」「ね」「ねk」「ねこ」が出る。上の欄はそのたびに検索した回数が増え、検索した文字に「n」「ね」「ねk」「ねこ」が並ぶ。Space で「猫」に変換すると、上の欄は「猫」でも検索し、5回になる。下の欄は変換中は0回のままで、Enter で確定したときに「猫」で1回だけ検索する
「ねこ」と打って「猫」に変換し、確定するまでに、2つの入力欄で検索が何回、どの文字で呼ばれたか。操作したコマだけ、右上のキーと、増えた検索の回数と文字をピンクにしている(キーの表示と、検索した回数と文字の表示は、記録した結果をもとに GIF 用に描いたもの。変換中の状態は CDP で作ったもの)

変換中もonChangeが呼ばれ、途中の文字で検索される

まず、onChange のたびに検索を呼ぶ入力欄を作りました。検索の代わりに、呼ばれた回数と渡された文字列を記録する search を用意しています。

ts
// 検索の代わり。呼ばれた回数と、渡された文字列を記録する
export const searchLog: string[] = [];

export function search(query: string) {
  searchLog.push(query);
}
tsx
import { useState, type ChangeEvent } from 'react';
import { search } from './search';

// 比べるための、onChange のたびに検索する入力欄
export function SearchNaive() {
  const [text, setText] = useState('');

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setText(e.target.value);
    search(e.target.value);
  }

  return <input aria-label="検索" value={text} onChange={handleChange} />;
}

この欄に、ローマ字入力で「ねこ」と打ち、「猫」に変換して確定する操作を送りました。検索は5回呼ばれ、確定する前の「n」「ね」「ねk」「ねこ」でも検索されています。続けて「じゃらし」と打って変換せずに確定すると、今度は8回呼ばれました。

text
「ねこ」と打って「猫」で確定
  検索の呼び出し: ["n","ね","ねk","ねこ","猫"]
続けて「じゃらし」と打って、そのまま確定
  検索の呼び出し: ["猫j","猫じ","猫じy","猫じゃ","猫じゃr","猫じゃら","猫じゃらs","猫じゃらし"]

React の公式リファレンスにある <input> の onChange の説明には、ユーザーが値を変えるとすぐに呼ばれる(キーを打つたびに呼ばれる)こと、ブラウザの input イベントと同じように動くことが書かれています。変換中の文字の変化も、その「値が変わった」に含まれます。

ブラウザのイベントも記録しました。「ねこ」を「猫」で確定するまでの間に、input イベントは6回起きていて、どれも isComposing=true でした。順番は次のとおりです(読みやすいように compositionupdate の行を省き、ほかの行も一部を省いています)。

text
compositionstart
input isComposing=true value=n
input isComposing=true value=ね
input isComposing=true value=ねk
input isComposing=true value=ねこ
input isComposing=true value=猫
input isComposing=true value=猫
compositionend data=猫

確定したときの最後の input も isComposing=true で、compositionend はそのあとに起きています。この最後の input は、値が1つ前と同じ「猫」でした。input イベントは6回でしたが、onChange から呼んだ検索は5回で、この最後の input では検索が呼ばれていません。

isComposingで飛ばすだけでは、確定した文字でも検索されない

変換中かどうかは、input イベントの isComposing で分かります。MDN の InputEvent.isComposing の説明によると、compositionstart のあと、compositionend より前に起きたイベントで true になります。

React の onChange で受け取るのは React のイベントオブジェクトです。React の公式リファレンスの、イベントオブジェクトの説明にあるとおり、ブラウザのもとのイベントは e.nativeEvent から読みます。ただし、TypeScript では e.nativeEvent の型が Event なので、そのまま isComposing を読むと型チェックでエラーになりました。

tsx
import type { ChangeEvent } from 'react';

// React の ChangeEvent の nativeEvent から isComposing を読めるかを、型チェックで確かめる
export function handleChange(e: ChangeEvent<HTMLInputElement>) {
  if (e.nativeEvent.isComposing) return;
}
text
src/ProbeIsComposing.tsx(5,21): error TS2339: Property 'isComposing' does not exist on type 'Event'.

そこで InputEvent として読み、変換中は検索を飛ばすようにしました。setText は変換中も呼んでいます。<input> の注意点の一覧にあるとおり、value を渡して state で管理する入力欄は、onChange の中でその値を同期的に更新する必要があるためです。SearchSkipComposing の handleChange は次のとおりです(ほかは SearchNaive と同じです)。

tsx
function handleChange(e: ChangeEvent<HTMLInputElement>) {
  setText(e.target.value);
  if ((e.nativeEvent as InputEvent).isComposing) return;
  search(e.target.value);
}

SearchSkipComposing に同じ操作を送ると、変換中の検索はなくなりましたが、確定した「猫」や「猫じゃらし」でも検索されませんでした。前の節の記録のとおり、確定したときの最後の input も isComposing=true だったからです。変換を使わずに「cat」と打ったときは、1文字ごとに検索されています。

text
「ねこ」と打って「猫」で確定
  検索の呼び出し: []
続けて「じゃらし」と打って、そのまま確定
  検索の呼び出し: []
続けて、変換なしで「cat」と打つ
  検索の呼び出し: ["猫じゃらしc","猫じゃらしca","猫じゃらしcat"]

onCompositionEndで、確定した文字の検索を動かす

変換が終わったときに呼ばれる onCompositionEnd で、確定した文字の検索を動かします。React の公式リファレンスの、共通の props の一覧によると、onCompositionEnd は IME が変換を終えたとき、または取り消したときに呼ばれます。

tsx
import { useState, type ChangeEvent, type CompositionEvent } from 'react';
import { search } from './search';

// 変換中は入力欄の表示だけを更新し、検索は変換が終わってから動かす
export function SearchAfterComposition() {
  const [text, setText] = useState('');

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setText(e.target.value); // 表示はいつも更新する
    if ((e.nativeEvent as InputEvent).isComposing) return; // 変換中は検索しない
    search(e.target.value);
  }

  function handleCompositionEnd(e: CompositionEvent<HTMLInputElement>) {
    search(e.currentTarget.value); // 変換が終わったときに検索する
  }

  return (
    <input aria-label="検索" value={text} onChange={handleChange} onCompositionEnd={handleCompositionEnd} />
  );
}

handleCompositionEnd では、e.data(確定した文字だけ)ではなく e.currentTarget.value を使っています。「猫」のあとに「じゃらし」を打ったとき、検索したいのは欄の文字全体の「猫じゃらし」だからです。

同じ操作を送ると、変換中は検索されず、「猫」と「猫じゃらし」で1回ずつ検索されました。変換を使わない「cat」の入力や Backspace では、SearchNaive と同じく1回ごとに検索されています(間に行った、変換を取り消す操作は次の節で扱うので、ここでは省いています)。

text
「ねこ」と打って「猫」で確定
  検索の呼び出し: ["猫"]
続けて「じゃらし」と打って、そのまま確定
  検索の呼び出し: ["猫じゃらし"]
続けて、変換なしで「cat」と打つ
  検索の呼び出し: ["猫じゃらしc","猫じゃらしca","猫じゃらしcat"]
Backspace を1回押す
  検索の呼び出し: ["猫じゃらしca"]

変換を取り消すと、onCompositionEndからもう一度検索される

前の節で触れたとおり、onCompositionEnd は変換を取り消したときにも呼ばれます。「猫じゃらしcat」のあとに「ね」まで打ち、確定せずに取り消しました(検証では、空の文字で確定させて取り消しを作りました)。欄の値は「猫じゃらしcat」に戻ります。

text
[onCompositionEnd で直接検索する書き方(SearchAfterComposition)]
  検索の呼び出し: ["猫じゃらしcat"]

取り消すまでの input イベントは、どれも isComposing=true でした。SearchAfterComposition では、取り消したときに onCompositionEnd から検索が呼ばれ、直前と同じ「猫じゃらしcat」でもう一度検索されています。

検索のたびにサーバーへリクエストを送る場合は、同じ文字での検索が重なるのを避けたいことがあります。そのときは、入力欄に出す text とは別に、検索に使う query を state で持ちます。onChange と onCompositionEnd では query を更新するだけにして、検索は query が変わったときに useEffect で動かします。

tsx
import { useEffect, useState, type ChangeEvent } from 'react';
import { search } from './search';

// 表示用の text と、検索に使う query を分ける。検索は query が変わったときだけ動く
export function SearchWithQuery() {
  const [text, setText] = useState('');
  const [query, setQuery] = useState('');

  useEffect(() => {
    if (query === '') return;
    search(query);
  }, [query]);

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setText(e.target.value);
    if ((e.nativeEvent as InputEvent).isComposing) return;
    setQuery(e.target.value);
  }

  return (
    <input
      aria-label="検索"
      value={text}
      onChange={handleChange}
      onCompositionEnd={(e) => setQuery(e.currentTarget.value)}
    />
  );
}

SearchWithQuery でも、「ねこ」を「猫」で確定するまでの検索は「猫」の1回だけで、「cat」の入力では1文字ごとに検索されました。同じように変換を取り消すと、検索は呼ばれませんでした。

text
[検索に使う state を分けた書き方(SearchWithQuery)]
  検索の呼び出し: []

取り消したあとも query は「猫じゃらしcat」のままで変わらないので、useEffect は動きません。この検証の取り消し方では、query を分けておくことで、同じ文字での検索が重なりませんでした。入力が止まるまで待つデバウンスと、この query を分ける書き方を組み合わせた例は、以前の記事「Reactで、入力が止まるまで検索を待つカスタムフックの作り方」で試しています。

​Posted by 86