Definition
If you have typed "안녕하세요" in Korean, clicked elsewhere, and found that only "안녕하세" was saved, this is the concept you need.
The commit boundary is the moment an edited value is treated as final and saved. Korean, Japanese, and Chinese have a composition phase in which several keystrokes combine into one character. If blur or Enter triggers a commit before that phase ends, the stored value differs from what the user sees on screen. This is not a problem to fix by cleaning up the value — it is a problem to fix by deferring the moment of commit.
Why it matters
Editing UIs written with only Latin input in mind conventionally save on onBlur and confirm on Enter. In Korean, those events can arrive while the last character is still being composed. The value is silently saved wrong, so the user only notices much later.
Worse, the arrival order of compositionend, keydown, and blur differs across browsers and IMEs. Fix it by assuming an order in one environment and it reappears in another. That is why the decision must be based on state — "am I composing right now?" — not on order.
How it works
- When Korean input begins, the browser fires
compositionstartand shows the uncommitted character. - Key events fired during composition carry
event.isComposing === true. - If blur happens in this window, the handler does not save; it only sets a "commit pending" flag.
- When composition ends,
compositionendfires and the value is final. - The
compositionendhandler sees the pending flag and performs the actual save.
| Event | Handling while composing | Reason |
|---|---|---|
keydown (Enter) | Ignore | It may be confirming the composition, not the field |
blur | Defer and mark pending | The value is still incomplete |
compositionend | Commit if pending | This is the real boundary |
change / input | Update local state only | Send to the server only at the commit boundary |
In practice
function EditableField({ initial, onCommit }: Props) {
const composingRef = useRef(false); // hooks run before any conditional early return
const pendingBlurRef = useRef(false);
const [value, setValue] = useState(initial);
const commit = () => onCommit(value);
return (
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
if (pendingBlurRef.current) {
pendingBlurRef.current = false;
commit();
}
}}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return; // Enter during composition is not a confirm
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
commit();
}
}}
onBlur={() => {
if (composingRef.current) {
pendingBlurRef.current = true; // defer until composition ends
return;
}
commit();
}}
/>
);
}
The regression test should assert that no save request is issued for some interval after a blur during composition. Watching for the request, not comparing the value, is the point.
Trade-offs
- Native
textareaplus a commit boundary: little code, and IME behaviour is exactly the browser default. You give up rich formatting and inline widgets. - Adopting a rich-text editor: you gain formatting, but the IME problem does not disappear. Composition sessions now fight the editor's re-renders, and the bundle grows.
- Deferred commit: saving is delayed by tens to hundreds of milliseconds — imperceptible, but if the component unmounts while pending, the save is lost, so the unmount path must check the flag too.
When not to use it
- Inputs that never involve an IME, such as numbers, dates, or enums. Tracking composition state is needless complexity there.
- Real-time collaboration that must transmit uncommitted characters as they are typed — that needs a design that marks text as provisional, not one that defers the commit.
Common mistakes
- Papering over it with string post-processing. "The text looks wrong, so let's de-duplicate characters" also destroys input where the user genuinely typed the same character twice. That is a boundary problem misdiagnosed as a value problem.
- Relying on
keyCode === 229. A legacy workaround that varies by browser. UseisComposingand the composition events. - Assuming event order. "blur is always followed by compositionend" is environment-dependent. Decide from state.
- Calling hooks after a conditional early return. Hook order changes between renders and state gets tangled.
- Verifying only with jsdom unit tests. They do not faithfully emulate the composition lifecycle; real-browser E2E is required.
Related concepts
- ime-composition-contenteditable — composition sessions broken inside rich-text editors
- contenteditable-keyboard-history-delegation — key events and edit-history boundaries
- race-safe-async-ui-requests — deferred commits and request races