ReactIMEi18nFrontendIndie Dev

React meets a Chinese IME: why onChange marks Zhuyin as a typo

·11 min read

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:

#EventdatavalueisComposingkeyCode
1keydown(empty)false229
2compositionstart(empty)(empty)
3compositionupdate(empty)
4inputtrue
5keydowntrue229
6compositionupdateㄋㄧ
7inputㄋㄧㄋㄧtrue
8keydownㄋㄧtrue229
9compositionupdateㄋㄧ
10inputtrue
11keydowntrue229
12compositionupdate你ㄏ
13input你ㄏ你ㄏtrue
14keydown你ㄏtrue229
15compositionupdate你ㄏㄠ你ㄏ
16input你ㄏㄠ你ㄏㄠtrue
17keydown你ㄏㄠtrue229
18compositionupdate你好你ㄏㄠ
19input你好你好true
20keydown你好true229
21compositionupdate你好你好
22input你好你好true
23compositionend你好你好
Chrome: the full native event sequence for typing 你好 with Zhuyin on macOS

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:

tsx
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
};
tsx
<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 / BraveSafari
Second to lastinput, value = 你好, isComposing: trueinput, value = 你好, isComposing: true
Lastcompositionend, value = 你好compositionend, value = 你好
Any input after that?NoNo
All three browsers end the same way: input first, compositionend last, and nothing after it

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:

js
// 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:

tsx
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:

ScenarioExpectedMeasured
Real sequence (input → compositionend)4949
Imagined sequence (compositionend → input)4848
Three passages, plain typing, no IME141141

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 Test

Finally

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.

#EventdatavalueisComposingkeyCode
1compositionstart(empty)(empty)
2compositionupdate(empty)
3inputtrue
4keydowntrue229
5compositionupdateㄋㄧ
6inputㄋㄧㄋㄧtrue
7keydownㄋㄧtrue229
8compositionupdateㄋㄧ
9inputtrue
10keydowntrue229
11compositionupdate你ㄏ
12input你ㄏ你ㄏtrue
13keydown你ㄏtrue229
14compositionupdate你ㄏㄠ你ㄏ
15input你ㄏㄠ你ㄏㄠtrue
16keydown你ㄏㄠtrue229
17compositionupdate你好你ㄏㄠ
18input你好你好true
19keydown你好true229
20input(empty)(empty)true
21input你好你好true
22compositionend你好你好
23keydown你好false229
Safari: the full native event sequence for typing 你好. Note the empty string in row 20.