React meets a Chinese IME: why onChange marks Zhuyin as a typo
The day I finished the typing test I ran it once myself: speed, accuracy, moving between passages. Everything worked. I thought I was done.
The next morning I opened it again and switched back to Zhuyin.
The whole screen turned red.
The passage began with 今天的天氣很適合出門走走. I pressed ㄐ and that character went red immediately — the comparison logic had decided ㄐ was not 今. I pressed ㄧ, then ㄣ, and it stayed red. Only when I picked the character and 今 actually appeared did the red turn green.
The reason I had missed it the day before is that I had tested in English.
The test was punishing people for not having finished typing — and only if they typed Chinese.
A problem English users never hit
With an English keyboard, a key is a character. You press a, an a lands in the field, done.
A Chinese input method does not work that way. ㄋ, ㄧ and ˇ are not results; they are intermediate states in a process called composition. Only after you select the character does 你 actually appear. Between the keypress and the character sits a stretch of time the browser calls a composition session.
The trouble is that the frameworks we use, the docs we read and the examples we copy all assume the first world.
React's onChange is really the native input event
First, a correction: React's onChange is not the native change event.
Native change waits for the field to lose focus. React's onChange fires on every keystroke, because React actually listens to the native input event.
And input keeps firing while composition is still in progress.
This is the full event sequence I measured typing 你好 with a real Zhuyin IME in Chrome:
| # | Event | data | value | isComposing | keyCode |
|---|---|---|---|---|---|
| 1 | keydown | (empty) | false | 229 | |
| 2 | compositionstart | (empty) | (empty) | ||
| 3 | compositionupdate | ㄋ | (empty) | ||
| 4 | input | ㄋ | ㄋ | true | |
| 5 | keydown | ㄋ | true | 229 | |
| 6 | compositionupdate | ㄋㄧ | ㄋ | ||
| 7 | input | ㄋㄧ | ㄋㄧ | true | |
| 8 | keydown | ㄋㄧ | true | 229 | |
| 9 | compositionupdate | 你 | ㄋㄧ | ||
| 10 | input | 你 | 你 | true | |
| 11 | keydown | 你 | true | 229 | |
| 12 | compositionupdate | 你ㄏ | 你 | ||
| 13 | input | 你ㄏ | 你ㄏ | true | |
| 14 | keydown | 你ㄏ | true | 229 | |
| 15 | compositionupdate | 你ㄏㄠ | 你ㄏ | ||
| 16 | input | 你ㄏㄠ | 你ㄏㄠ | true | |
| 17 | keydown | 你ㄏㄠ | true | 229 | |
| 18 | compositionupdate | 你好 | 你ㄏㄠ | ||
| 19 | input | 你好 | 你好 | true | |
| 20 | keydown | 你好 | true | 229 | |
| 21 | compositionupdate | 你好 | 你好 | ||
| 22 | input | 你好 | 你好 | true | |
| 23 | compositionend | 你好 | 你好 |
Look at rows 4 and 7. Composition has not finished, input has already fired, and value holds ㄋ and ㄋㄧ — Zhuyin symbols, not text.
Typing the two characters 你好 fired input nine times. Eight of those carried a meaningless intermediate state.
If your onChange compares, validates, counts characters or calls an API, all of it runs nine times on half-finished input. That is exactly how my typing test decided ㄋ was a mistake.
Fixes that look reasonable but are not
Strip Zhuyin symbols with a regular expression. This only treats the symptom. Cangjie produces Latin letters and so does Pinyin, so there is nothing to strip — and you swallow real input when someone genuinely wants to type letters.
Debounce by a few hundred milliseconds. Fast typists still land inside a composition, and the live comparison becomes sluggish.
Switch to onBlur. Validating only on blur removes the live feedback, which for a typing test removes the feature.
Check keyCode === 229 on keydown. A common pattern in older codebases. 229 is the historical signal for "the IME swallowed this key", and all three browsers still send it. But composition events have replaced it, and its position differs per browser (more on that below), so it is a poor primary signal.
The right direction: composition events
Browsers describe the composition process directly: compositionstart when composition begins, compositionupdate when its content changes, and compositionend once the character is selected and the text is final.
The principle fits in a sentence: keep updating the screen during composition, but do not make decisions. Wait for compositionend before treating the value as real.
Roughly:
const composingRef = useRef(false);
const handleChange = (event) => {
const { value } = event.target;
setTyped(value); // the screen keeps up, so people see what they are typing
if (!composingRef.current) {
commitValue(value); // but only a settled value counts
}
};
const handleCompositionEnd = (event) => {
composingRef.current = false;
commitValue(event.target.value); // selection is done, so this value is real
};<textarea
value={typed}
onChange={handleChange}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={handleCompositionEnd}
/>Note that the screen still follows every input event. People need to see that they are typing ㄋㄧ — that feedback is the whole point of an input method. We simply do not judge those intermediate values.
Why useRef and not useState
composingRef uses useRef rather than useState, not for performance, but because it has to be readable within the same event loop.
useState updates asynchronously and only takes effect on the next render. The flag set by compositionstart would still read as its old value inside the handleChange that follows — and those two often happen in the same batch of events. The guard would simply not work.
A ref takes effect immediately. This is one of the few cases where useRef is genuinely required rather than a shortcut around state.
Most articles on this topic stop here. I thought I was finished too.
The second layer: the last input still reports isComposing: true
To check that I actually understood the ordering, I wrote a small tool that logs compositionstart, compositionupdate, compositionend, input and keydown, then typed 你好 once in each of three browsers with a real Zhuyin IME.
The result surprised me: all three end the session identically.
| Chrome / Brave | Safari | |
|---|---|---|
| Second to last | input, value = 你好, isComposing: true | input, value = 你好, isComposing: true |
| Last | compositionend, value = 你好 | compositionend, value = 你好 |
| Any input after that? | No | No |
Two facts, taken together. First, no input event fires after compositionend. Second, the last input already carries the final value 你好, yet isComposing is still true.
That breaks a widely repeated pattern:
// never receives the committed text in Chrome, Brave or Safari
onChange = (event) => {
if (event.nativeEvent.isComposing) return;
commit(event.target.value);
};InputEvent.isComposing looks elegant — no ref to maintain, one line and done. The problem is that the input carrying the final value has isComposing set to true, so it gets returned out of, and no further input arrives after it.
So you wait for that value forever.
The conclusion is clear: compositionend is not an optional refinement, it is the only place the settled value arrives. isComposing can filter out intermediate states, but it cannot replace compositionend.
Three details I picked up along the way
compositionupdate's value lags by one step. In row 3, compositionupdate already carries ㄋ as its data, while target.value is still an empty string — the DOM value only catches up on the next input event. Read the value from input or compositionend, never from target.value inside compositionupdate.
Safari flashes an empty string before committing. Just before the text settles, Safari sends an extra input whose value is an empty string, and only the following input restores 你好. Any logic along the lines of "reset state when the field empties" will fire there.
All three still send keyCode 229, but in different places. Chrome and Brave add a keydown before composition starts; Safari has no leading one but adds a keydown after compositionend. Using it to decide whether composition has finished is unreliable.
A guard I added that may never be needed
Before I had measured the real ordering, I simulated composition with synthetic events — and guessed the order wrong. I assumed an input event followed compositionend. Under that imagined sequence the same passage settles twice: the character count doubles and the passage index skips one.
So I added an idempotence guard:
const settledValueRef = useRef(''); // the passage text already counted
const commitValue = (value) => {
if (value.length >= passage.length) {
if (value === settledValueRef.current) return; // settle a passage only once
settledValueRef.current = value;
// …settle
return;
}
committedRef.current = value;
};Then the real measurements overturned my premise: none of the three browsers send an input after compositionend, so the path I was guarding against does not occur in practice.
Should the guard stay? I kept it, but for a different reason.
The original reason was "prevent a known bug". The reason now is that React's onChange does not map one-to-one onto the native input event. React runs its own change detection, and whether it ever synthesises an extra onChange after compositionend is something I have not verified — and it is internal behaviour that can change between versions.
The guard costs one string comparison. Paying that instead of betting on an assumption I cannot back up is a good trade.
Why the key is the text, not the passage index
My first version keyed the guard on the passage index: if this passage has already settled, skip.
That key is unstable. It moves with setPassageIndex, so if a single re-render lands between the two calls, the second one reads the new index and the guard is bypassed. Whether a re-render happens in between is up to React's scheduler, not something you can guarantee.
settledValueRef holds the text itself, and a ref is the same object across renders, unaffected by re-rendering.
When you need idempotence in React, key it on something that does not change when state updates. Any value that tracks state is a poor answer to "has this already happened?".
Two wrong turns
I finished the guard and ran the test. The number was still doubled.
I stared at that 98 for a long time, started doubting the whole approach, and was ready to throw it out and rewrite.
It turned out I was measuring the wrong thing. The counter on screen shows settled characters plus whatever is currently in the input box, and my simulation had stuffed the whole passage back into the box. So it displayed 49 + 49 = 98.
I emptied the box and read it again: 49. It had been correct the entire time.
The fix was never broken. I nearly discarded it over a misread number and went off to fix a problem that did not exist.
The second time was the same mistake in a different place.
For a while I believed React deferred onCompositionEnd, because I dispatched compositionend, read the screen immediately and saw nothing change. I even wrote up that "finding". But React simply had not re-rendered yet; the handler had already run.
Only when I put the log inside the handler instead of watching from outside did I get the real ordering.
Both times the same illness: treating the number on screen as the state inside the program.
In React those two are separated by an asynchronous render. To verify internal state, measure from the inside. I would have said that before this — but saying it clearly is not the same as doing it.
Verification
After the fix I ran three scenarios:
| Scenario | Expected | Measured |
|---|---|---|
| Real sequence (input → compositionend) | 49 | 49 |
| Imagined sequence (compositionend → input) | 48 | 48 |
| Three passages, plain typing, no IME | 141 | 141 |
The second row is deliberate. Real browsers do not currently produce that order, but that is an observation today, not a guarantee in a spec — input methods and browser versions both change. Where you can avoid betting on ordering, do not bet.
Typing tests are not the only thing that breaks
The example here is a typing game, but the same trap appears in any Chinese input surface that reacts live. Search-as-you-type fires nine requests for 你好, eight of them querying things like ㄋ and ㄋㄧ. Character limits count composing Zhuyin symbols and block people before they have finished. Live form validation keeps a field red while someone is still typing. A chat "typing…" indicator sends a status update per Zhuyin symbol.
A few practical notes
Whether to block pasting. My typing test blocks onPaste, because pasting is not typing. If your surface is not a test, you usually should not block it.
Android soft keyboards. Some Android input methods stay in a composition session even for English, and compositionend may arrive late or behave differently from desktop. I have not measured this, so I will not draw a conclusion.
Do not update the screen only on compositionend. People need to see what they are typing. Following every input event is correct; we simply do not judge those values.
The implementation described here: Chinese Typing Speed TestFinally
This bug will not show up in any English tutorial, because the people writing them do not use a Chinese input method.
The first layer — onChange firing during composition — has been written about, and there are a few articles in Chinese. But the second layer, that the last input still reports isComposing: true and nothing follows it, I could not find in Chinese at all, and the English material is scattered.
The frameworks we use, the docs we read and the examples we copy all assume a world where one key is one character. That world has no composition in it. Chinese-language developers have to fill in this part themselves — and then write it down, so the next person steps in it once less.
If your product has Chinese users, go and type a line in Zhuyin. Do not test only in English.
Appendix: the full Safari sequence
Measured on macOS with a Zhuyin input method, typing 你好 including character selection. Brave matches Chrome row for row, so it is not repeated here.
| # | Event | data | value | isComposing | keyCode |
|---|---|---|---|---|---|
| 1 | compositionstart | (empty) | (empty) | ||
| 2 | compositionupdate | ㄋ | (empty) | ||
| 3 | input | ㄋ | ㄋ | true | |
| 4 | keydown | ㄋ | true | 229 | |
| 5 | compositionupdate | ㄋㄧ | ㄋ | ||
| 6 | input | ㄋㄧ | ㄋㄧ | true | |
| 7 | keydown | ㄋㄧ | true | 229 | |
| 8 | compositionupdate | 你 | ㄋㄧ | ||
| 9 | input | 你 | 你 | true | |
| 10 | keydown | 你 | true | 229 | |
| 11 | compositionupdate | 你ㄏ | 你 | ||
| 12 | input | 你ㄏ | 你ㄏ | true | |
| 13 | keydown | 你ㄏ | true | 229 | |
| 14 | compositionupdate | 你ㄏㄠ | 你ㄏ | ||
| 15 | input | 你ㄏㄠ | 你ㄏㄠ | true | |
| 16 | keydown | 你ㄏㄠ | true | 229 | |
| 17 | compositionupdate | 你好 | 你ㄏㄠ | ||
| 18 | input | 你好 | 你好 | true | |
| 19 | keydown | 你好 | true | 229 | |
| 20 | input | (empty) | (empty) | true | |
| 21 | input | 你好 | 你好 | true | |
| 22 | compositionend | 你好 | 你好 | ||
| 23 | keydown | 你好 | false | 229 |