The phase before a character exists
For Japanese, Chinese, and Korean input, a keystroke isn't a character yet. Everything you wired to onChange is reacting to a draft.
For Japanese, Chinese, and Korean, and for predictive input in Arabic and Vietnamese, there's a program sitting between the keyboard and your input field. It's called an input method editor. It turns a run of keystrokes into a list of candidates, and the user picks one.
Type nihon and the IME shows にほん in a provisional buffer, offers 日本 and 二本 and a few others, then waits. Nothing is committed yet. The value in your field is a draft the user is still negotiating with.
Between compositionstart and compositionend, that value is the IME thinking out loud. It matches neither what the user pressed nor what they intend to keep.
Here's a simulated IME so you can watch the sequence without installing one. Click the field, type nihon, press space, then Enter.
What actually fires
space cycles candidates · enter commits · escape cancels
Event stream
Waiting for a keystroke.
Every keydown during composition reports 229. The real key never reaches you.
Every keydown in that stream reports keyCode 229.
That number is a fossil. It's VK_PROCESSKEY from the Win32 API, meaning the input method consumed this key and it isn't yours. Browsers inherited it and never let it go. event.key reports "Process". The letter the user actually pressed is gone.
So any keydown handler that switches on event.key is, during composition, switching on a sentinel.
Search as you type
The first thing this breaks is anything reacting to input.
Search as you type
space cycles candidates · enter commits · escape cancels
onInput={(e) => search(e.target.value)}if (e.nativeEvent.isComposing) returnType nihon, press space, press enter. Both panels see the same keystrokes.
The left panel is the handler almost everyone writes. Composition emits an input event for every keystroke and every candidate change, so one word produces nine requests. The first eight are queries nobody asked for, and several contain raw romaji that means nothing in any language.
Debouncing reduces the count and delays the garbage. The last debounced value can still land mid-composition, and then it's late as well as wrong.
The fix is one line, on the native event:
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value)
// still in flight, so don't act on a draft
if ((event.nativeEvent as InputEvent).isComposing) return
search(event.target.value)
}Keep updating state. The field has to stay responsive or the IME has nothing to render into. What you gate is the side effect: the request, the validation, the autosave, the analytics call.
The Enter key
While a candidate list is open, Enter selects a candidate. It doesn't submit anything. The user is mid-word choosing between 日本 and 二本, and Enter is how they say "that one".
The enter key means two things
type a word, then press enter to commit it
if (e.key === 'Enter') submit()if (e.key === 'Enter' && !e.nativeEvent.isComposing) submit()Type nihon, then press enter once to pick a candidate. One of these forms just searched for nothing.
With if (event.key === 'Enter') submit() you've just searched for an empty string while someone was picking a kanji. They'll press Enter again, because they still have to pick it, and you'll submit again.
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter') return
if (event.nativeEvent.isComposing) return // picking a candidate
submit()
}isComposing lives on the native KeyboardEvent, not React's synthetic wrapper, so event.nativeEvent isn't optional there.
One ordering caveat. Where compositionend falls relative to the final input event has differed between engines historically. Read the field's value when you need it rather than reconstructing it from the event sequence.
Everywhere else
The same assumption shows up all over a normal codebase.
- Character counters count the provisional buffer.
にほんreads as 3 while the user is deciding, then becomes 2 when 日本 commits. A counter going backwards looks like a bug in the counter, and it is one. - Validation on change rejects half-formed input and shows an error to someone typing correctly.
- Autosave persists a phonetic draft.
- Controlled inputs that transform the value (uppercasing, stripping characters, reformatting) write a modified string back into a field with a live composition attached, and the IME loses its buffer. This is behind most "Chinese input doesn't work in this React app" reports.
- Analytics on keystroke inflates every metric you have for entire regions.
The transform case doesn't get fixed by isComposing. It gets fixed by not writing to the field while a composition is open. Buffer the normalization and apply it on compositionend.
Why it stays hidden
In Latin script the composition phase is empty. compositionstart never fires, every keystroke is a character, isComposing is always false. All of this is dormant.
It's dormant for your team, your QA, and your staging environment. It isn't dormant for the roughly one and a half billion people whose scripts need an IME to type at all, and they aren't going to file a bug explaining VK_PROCESSKEY to you.
The fix is two conditionals. Knowing there was a phase in there at all is the part that takes a while.