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

click here, then type “nihon”

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

click here, then type “nihon”

space cycles candidates · enter commits · escape cancels

Fires on every input0 requests
onInput={(e) => search(e.target.value)}
No requests.
Waits for the commit0 requests
if (e.nativeEvent.isComposing) return
No requests.

Type 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

click here, then type “nihon”

type a word, then press enter to commit it

Submits on any enterif (e.key === 'Enter') submit()
Nothing submitted.
Checks the composition flagif (e.key === 'Enter' && !e.nativeEvent.isComposing) submit()
Nothing submitted.

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.