Two Pitfalls with IME Composition in React: Enter to Submit and Debounced Auto-Search

Two Pitfalls with IME Composition in React: Enter to Submit and Debounced Auto-Search

The causes and fixes for two problems are explained: forms being accidentally submitted when pressing Enter during Japanese input (IME) conversion, and debounced auto-search firing on uncommitted IME characters. Two approaches using the isComposing property and composition events are compared.
2026.06.19

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 "東京":

  1. Type the roman characters t, o, k, y, o
  2. The IME displays the reading "とうきょう" (with underline)
  3. Press the space key to display conversion candidates
  4. 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.

react-ime-composition-pitfalls-ime-flow

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.

react-ime-composition-pitfalls-debounce-timing

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.

Share this article