
Two Pitfalls with IME Composition in React: Enter to Submit and Debounced Auto-Search
This page has been translated by machine translation. View original
Introduction
Have you ever experienced a form being submitted the moment you press the Enter key to confirm a kanji conversion while entering text in a form using Japanese input (IME)?
I encountered exactly this problem in a chat UI built with Next.js + React. When entering a message in Japanese, the message would be unintentionally sent the moment I pressed Enter during the conversion phase (while converting), when the kanji candidates were displayed with an underline.
This article covers everything from identifying the cause to the fix.
Environment
- Next.js 15
- React 19
- TypeScript
Cause of the Problem
IME Conversion Flow and Keyboard Events
When entering Japanese, Chinese, or Korean (CJK) text, the OS's IME (Input Method Editor) is involved. For example, when typing "東京":
- Type the roman characters
t,o,k,y,o - The IME displays the reading "とうきょう" (with underline)
- Press the space key to display conversion candidates
- Press Enter to confirm the conversion
The conflict between the Enter key used in step 4 to "confirm the conversion" and the Enter key used to "submit the form" is the problem.

How Browsers Distinguish Between the Two
Browsers indicate whether an IME conversion is in progress via the KeyboardEvent.isComposing property.
| State | isComposing |
|---|---|
| Normal key input | false |
| Key input during IME conversion | true |
Problematic Code
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(); // Also triggered when confirming a conversion
}
}
Because this code does not check isComposing, handleSubmit() is called even when confirming a kanji conversion (isComposing: true).
The Fix
Simply add e.nativeEvent.isComposing as a condition.
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSubmit();
}
}
e.nativeEvent is a property for accessing the native DOM event from React's SyntheticEvent. Since isComposing does not exist on React's SyntheticEvent, it is retrieved via e.nativeEvent.isComposing.
The Same Applies to <input>
The same problem occurs not only with <textarea> but also with <input type="text">. The fix is the same:
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
submitFreeText();
}
}
Advanced: Conflict Between Debounced Auto-Search and IME Conversion
Even after fixing the Enter key issue, there is another tricky case: UIs that trigger an auto-search via debounce while the user is typing.

The Problem
It is common to implement a search form that executes an auto-search with a debounce (e.g., 1000ms) on every keystroke. However, during Japanese text input, onChange (or onValueChange provided by components like shadcn/ui's CommandInput) fires every time an IME intermediate character (an uncommitted character with an underline) is entered. This means the debounce callback fires before the conversion is confirmed, and the search is executed with an intermediate, uncommitted state.
// Problematic code
const handleChange = (value: string) => {
setQuery(value);
// Also fires during IME conversion
searchDebounce(() => executeSearch(), 1000);
};
Solution: Two Approaches
Approach 1: Track IME Conversion State with useRef
Use compositionstart / compositionend events to manage a conversion-in-progress flag with useRef, and use it to guard the debounced search.
// hooks
const isComposingRef = useRef(false);
const handleChange = useCallback(
(value: string) => {
setQuery(value);
if (!isComposingRef.current) {
searchDebounce(() => executeSearch(), DEBOUNCE_MS);
}
},
[searchDebounce, executeSearch],
);
// component
<input
onChange={(e) => handleChange(e.target.value)}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => {
isComposingRef.current = false;
searchDebounce(() => executeSearch(), DEBOUNCE_MS);
}}
/>
Approach 2: Directly Check isComposing on the onInput Event
Without using useRef, directly check the native InputEvent.isComposing inside an event handler.
// hooks — remove searchDebounce from handleChange
const handleChange = useCallback(
(value: string) => {
setQuery(value);
},
[],
);
const triggerSearch = useCallback(() => {
searchDebounce(() => executeSearch(), DEBOUNCE_MS);
}, [searchDebounce, executeSearch]);
// component
<input
onChange={(e) => handleChange(e.target.value)}
onInput={(e: React.FormEvent<HTMLInputElement>) => {
if (!(e.nativeEvent as InputEvent).isComposing) {
triggerSearch();
}
}}
onCompositionEnd={() => {
triggerSearch();
}}
/>
Comparison of the Two Approaches
| useRef | onInput event | |
|---|---|---|
| Amount of change | ~10 lines | ~15 lines |
| Scope of impact | Small — just adds a guard to the existing flow | Medium — moves responsibility for triggering the search to the component side |
| Accuracy | No race conditions since it updates synchronously | No issues in modern browsers. There are reports of cases in some versions of Safari 16 and earlier where isComposing remains true on the input event immediately after compositionend (supplemented by onCompositionEnd) |
| Testability | Easy to mock the ref | Requires simulating native InputEvent + CompositionEvent |
Watch Out for Conflicts with Form Watching (form.watch, etc.)
If you are triggering an auto-search by watching the entire form for changes, such as with react-hook-form's form.watch, setValue on a text field will also trigger the watch, causing a search to fire even during IME conversion. This is because even if you are controlling the debounce for text input with the approaches above, the watch triggers the search through a separate path.
You need to check the field name inside the watch callback and skip changes from the text input.
useEffect(() => {
const subscription = form.watch((_values, { type, name }) => {
if (type !== "change" || !name) return;
// Skip changes from text input, as they are controlled by onInput/onCompositionEnd
if (name === "query") return;
searchDebounce(() => executeSearch(), DEBOUNCE_MS);
});
return () => subscription.unsubscribe();
}, [form, searchDebounce, executeSearch]);
Summary
The Enter Key Issue
| Before Fix | After Fix | |
|---|---|---|
| Enter to confirm conversion | Form is submitted | Conversion only |
| Normal Enter | Form is submitted | Form is submitted |
The fix is just one line:
&& !e.nativeEvent.isComposing
The Debounced Auto-Search Issue
| Before Fix | After Fix | |
|---|---|---|
| Input during IME conversion | Search fires after debounce | Search is skipped |
| After conversion is confirmed | Search fires after debounce | Search fires after debounce |
Either approach can fix this, but if you want to minimize the scope of impact, useRef is the way to go; if you want to avoid side effects and use the event's information directly, the onInput event approach is more suitable.
In web apps that handle CJK languages, not only Enter key handling but also any debounce-based auto-execution needs to take the IME conversion state into account.