The input Event Lies While You're Composing
One Chinese word fires six input events carrying pinyin the user never typed. Replay a real IME trace and fix it in thirty lines.
Your search box fires one request per keystroke — you debounced it, so that’s fine. Then a Chinese user types one word and your handler sees n, ni, nih, niha, nihao, and finally 你好. Five of those six values are romanised keystrokes the user never typed as text and never meant to search for.
An IME — the input method that turns pinyin into Chinese, romaji into kana, jamo into Hangul — sits between the keyboard and your input. While it is composing, the browser still fires input for every intermediate state, because the input’s value genuinely is changing. The events are not wrong; they are just not what you wanted. Hit replay above and watch the counters: six input events, one word.
The signal you were missing
The browser tells you, if you ask. Three composition events bracket the whole interaction — compositionstart, compositionupdate, compositionend — and every native input event carries an isComposing flag. Ignore input while composing, act on compositionend, and the noise disappears.
The ordering trap
Here is where most implementations break. Chrome fires the final input before compositionend; Safari and Firefox fire it after. So “commit on compositionend” drops the last value on one family of browsers, and “commit on the input after compositionend” drops it on the other. Both approaches look correct on the machine you developed on.
The fix is not to detect the browser. Commit on compositionend, commit on any input that is not composing, and drop any commit identical to the previous one. Whichever order the events arrive in, exactly one commit survives.
Why it is worse than wasted requests
Over-firing a search is the visible symptom. The quieter bug is a controlled React input that rewrites value mid-composition — trimming, upper-casing, filtering characters. Replacing the value while the IME owns it can close the candidate window, drop the pre-edit text, or leave the composition and the DOM disagreeing about what was typed. Any transformation of input text should wait until composition has ended.
The guard
Below is the exact reducer the demo runs. It is framework-free and about thirty lines of real logic — the value is in the rules it encodes, not its size.
/**
* A composition-aware guard for text inputs.
*
* While an IME is composing — pinyin for Chinese, kana for Japanese, jamo for
* Korean — the browser fires `input` for every intermediate keystroke. Those
* events carry half-finished text ("ni", "nih", "niha") that the user never
* meant to submit. A naive search-as-you-type box fires a request for each one,
* and worse, a controlled React input that rewrites `value` mid-composition can
* tear the IME's candidate window apart.
*
* The fix is a two-line state machine, but it has to handle one browser quirk:
* Chrome fires the final `input` *before* `compositionend`, while Safari and
* Firefox fire it *after*. Anything that assumes an order breaks on some
* browser. The reducer below assumes neither — it commits on `compositionend`
* and on any `input` that is not part of a composition, and dedupes the result.
*
* Pure and framework-free on purpose: the demo on this page and the unit tests
* drive the exact same reducer.
*/
export type CompositionEventName =
| "compositionstart"
| "compositionupdate"
| "compositionend"
| "input";
export interface GuardEvent {
type: CompositionEventName;
/** The input's value at the moment the event fired. */
value: string;
/**
* `event.isComposing` as reported by the browser. Only meaningful on `input`;
* the composition events set it implicitly.
*/
isComposing?: boolean;
}
export interface GuardState {
/** True between compositionstart and compositionend. */
composing: boolean;
/** The last value handed downstream — used to suppress duplicate commits. */
lastCommitted: string;
}
export interface GuardResult {
state: GuardState;
/**
* The value downstream work (fetch, filter, router push) should use, or null
* when this event must be ignored.
*/
commit: string | null;
}
export const initialGuardState: GuardState = { composing: false, lastCommitted: "" };
/**
* Advance the guard by one event.
*
* The rules, in full:
* - `compositionstart` opens the window; nothing commits.
* - `compositionupdate` is intermediate candidate text; never commits.
* - `input` commits only when no composition is in progress.
* - `compositionend` closes the window and always commits the final value,
* because in Chrome the `input` that carried it already arrived while
* `composing` was still true.
* - Any commit equal to the previous one is dropped, which absorbs the
* browser-order difference without branching on the user agent.
*/
export function guardReducer(state: GuardState, event: GuardEvent): GuardResult {
switch (event.type) {
case "compositionstart":
return { state: { ...state, composing: true }, commit: null };
case "compositionupdate":
return { state, commit: null };
case "compositionend": {
const next: GuardState = { composing: false, lastCommitted: event.value };
// Safari/Firefox order: input already committed this exact value.
const commit = event.value === state.lastCommitted ? null : event.value;
return { state: next, commit };
}
case "input": {
// `isComposing` is authoritative when present; the tracked flag is the
// fallback for browsers (and synthetic events) that omit it.
const composing = event.isComposing ?? state.composing;
if (composing) return { state, commit: null };
if (event.value === state.lastCommitted) return { state, commit: null };
return { state: { composing: false, lastCommitted: event.value }, commit: event.value };
}
}
}
/**
* Run a whole event sequence and collect what a downstream consumer would see.
* Handy for tests, and for the "naive vs guarded" comparison in the demo.
*/
export function runSequence(
events: readonly GuardEvent[],
from: GuardState = initialGuardState,
): { state: GuardState; commits: string[] } {
let state = from;
const commits: string[] = [];
for (const event of events) {
const result = guardReducer(state, event);
state = result.state;
if (result.commit !== null) commits.push(result.commit);
}
return { state, commits };
}
/**
* What a naive handler sees: one commit per `input` event, composition ignored.
* This is the baseline the demo counts against.
*/
export function runNaive(events: readonly GuardEvent[]): string[] {
return events.filter((event) => event.type === "input").map((event) => event.value);
}