Reactで、日本語の変換が終わってから検索を動かす方法
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が呼ばれ、途中の文字で検索される
まず、onChange のたびに検索を呼ぶ入力欄を作りました。検索の代わりに、呼ばれた回数と渡された文字列を記録する search を用意しています。
// 検索の代わり。呼ばれた回数と、渡された文字列を記録する
export const searchLog: string[] = [];
export function search(query: string) {
searchLog.push(query);
}
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回呼ばれました。
「ねこ」と打って「猫」で確定
検索の呼び出し: ["n","ね","ねk","ねこ","猫"]
続けて「じゃらし」と打って、そのまま確定
検索の呼び出し: ["猫j","猫じ","猫じy","猫じゃ","猫じゃr","猫じゃら","猫じゃらs","猫じゃらし"]
React の公式リファレンスにある <input> の onChange の説明には、ユーザーが値を変えるとすぐに呼ばれる(キーを打つたびに呼ばれる)こと、ブラウザの input イベントと同じように動くことが書かれています。変換中の文字の変化も、その「値が変わった」に含まれます。
ブラウザのイベントも記録しました。「ねこ」を「猫」で確定するまでの間に、input イベントは6回起きていて、どれも isComposing=true でした。順番は次のとおりです(読みやすいように compositionupdate の行を省き、ほかの行も一部を省いています)。
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 を読むと型チェックでエラーになりました。
import type { ChangeEvent } from 'react';
// React の ChangeEvent の nativeEvent から isComposing を読めるかを、型チェックで確かめる
export function handleChange(e: ChangeEvent<HTMLInputElement>) {
if (e.nativeEvent.isComposing) return;
}
src/ProbeIsComposing.tsx(5,21): error TS2339: Property 'isComposing' does not exist on type 'Event'.
そこで InputEvent として読み、変換中は検索を飛ばすようにしました。setText は変換中も呼んでいます。<input> の注意点の一覧にあるとおり、value を渡して state で管理する入力欄は、onChange の中でその値を同期的に更新する必要があるためです。SearchSkipComposing の handleChange は次のとおりです(ほかは SearchNaive と同じです)。
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文字ごとに検索されています。
「ねこ」と打って「猫」で確定
検索の呼び出し: []
続けて「じゃらし」と打って、そのまま確定
検索の呼び出し: []
続けて、変換なしで「cat」と打つ
検索の呼び出し: ["猫じゃらしc","猫じゃらしca","猫じゃらしcat"]
onCompositionEndで、確定した文字の検索を動かす
変換が終わったときに呼ばれる onCompositionEnd で、確定した文字の検索を動かします。React の公式リファレンスの、共通の props の一覧によると、onCompositionEnd は IME が変換を終えたとき、または取り消したときに呼ばれます。
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回ごとに検索されています(間に行った、変換を取り消す操作は次の節で扱うので、ここでは省いています)。
「ねこ」と打って「猫」で確定
検索の呼び出し: ["猫"]
続けて「じゃらし」と打って、そのまま確定
検索の呼び出し: ["猫じゃらし"]
続けて、変換なしで「cat」と打つ
検索の呼び出し: ["猫じゃらしc","猫じゃらしca","猫じゃらしcat"]
Backspace を1回押す
検索の呼び出し: ["猫じゃらしca"]
変換を取り消すと、onCompositionEndからもう一度検索される
前の節で触れたとおり、onCompositionEnd は変換を取り消したときにも呼ばれます。「猫じゃらしcat」のあとに「ね」まで打ち、確定せずに取り消しました(検証では、空の文字で確定させて取り消しを作りました)。欄の値は「猫じゃらしcat」に戻ります。
[onCompositionEnd で直接検索する書き方(SearchAfterComposition)]
検索の呼び出し: ["猫じゃらしcat"]
取り消すまでの input イベントは、どれも isComposing=true でした。SearchAfterComposition では、取り消したときに onCompositionEnd から検索が呼ばれ、直前と同じ「猫じゃらしcat」でもう一度検索されています。
検索のたびにサーバーへリクエストを送る場合は、同じ文字での検索が重なるのを避けたいことがあります。そのときは、入力欄に出す text とは別に、検索に使う query を state で持ちます。onChange と onCompositionEnd では query を更新するだけにして、検索は query が変わったときに useEffect で動かします。
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文字ごとに検索されました。同じように変換を取り消すと、検索は呼ばれませんでした。
[検索に使う state を分けた書き方(SearchWithQuery)]
検索の呼び出し: []
取り消したあとも query は「猫じゃらしcat」のままで変わらないので、useEffect は動きません。この検証の取り消し方では、query を分けておくことで、同じ文字での検索が重なりませんでした。入力が止まるまで待つデバウンスと、この query を分ける書き方を組み合わせた例は、以前の記事「Reactで、入力が止まるまで検索を待つカスタムフックの作り方」で試しています。





