If you've ever shipped a Rork app to Japanese users, there's a good chance you've seen feedback like "the search box breaks when I type Japanese" or "the conversion closes before I can confirm." These are symptoms of a category of bugs that rarely surface in English testing: React Native's TextInput handling during IME composition.
I learned this the hard way. Shortly after releasing a Rork-built note-taking app on the App Store, I spotted a one-star review saying "can't confirm Japanese conversion, buggy." The code ran fine in English — but because Rork's generated implementation didn't anticipate Input Method Editors (IMEs), it broke for every Japanese user.
This article walks through why this happens, what it looks like in practice, and how to fix the most common patterns in code you can apply to your Rork project today.
Why events fire during IME composition
React Native's TextInput.onChangeText was designed with alphabet-style input in mind: one keystroke, one character, one event. That model breaks down for Japanese, Chinese, and Korean (CJK) input methods.
When a user types Japanese, the IME first shows a "preedit" string — hiragana with an underline, not yet confirmed. Each intermediate character fires onChangeText. Typing "東京駅" might trigger 5 to 10 events before confirmation. Any work you do in that handler runs during composition.
This manifests as:
- Search boxes firing API calls for every intermediate character, hitting rate limits and producing noisy network logs
- State updates causing rerenders that close the IME candidate window mid-selection
onSubmitEditingfiring unexpectedly when the user hits Enter to confirm, not submit
The web platform handles this through compositionstart / compositionupdate / compositionend events, but React Native exposes no equivalent. You have to work around it.
Reproducing the problem
Here's the minimal Rork-style snippet that demonstrates the issue. Try it in a Japanese-enabled simulator or device.
import { useState } from "react";
import { TextInput, View, Text } from "react-native";
export default function BrokenSearchBox() {
const [query, setQuery] = useState("");
const [callCount, setCallCount] = useState(0);
// ❌ Bad: fires during composition, not just on confirmation
const handleChange = (text: string) => {
setQuery(text);
setCallCount((c) => c + 1);
console.log("Search API called with:", text);
};
return (
<View style={{ padding: 24 }}>
<TextInput
value={query}
onChangeText={handleChange}
placeholder="Search..."
style={{ borderWidth: 1, padding: 12, borderRadius: 8 }}
/>
<Text>API call count: {callCount}</Text>
</View>
);
}Type "東京駅" (Tokyo Station) and watch callCount climb to roughly 20 before you confirm. That's 20 API calls for a single search — clearly not shippable.
Fix 1: Debounce the input
The simplest fix is to buffer rapid changes and only act once the user pauses. This doesn't require knowing anything about IMEs — it just smooths out the burst.
import { useState, useRef, useEffect } from "react";
import { TextInput, View, Text } from "react-native";
export default function DebouncedSearchBox() {
const [inputValue, setInputValue] = useState("");
const [committedQuery, setCommittedQuery] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
// 300ms of silence counts as "user finished typing"
timerRef.current = setTimeout(() => {
setCommittedQuery(inputValue);
}, 300);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [inputValue]);
useEffect(() => {
if (committedQuery.length === 0) return;
console.log("Search API called (post-commit):", committedQuery);
}, [committedQuery]);
return (
<View style={{ padding: 24 }}>
<TextInput
value={inputValue}
onChangeText={setInputValue}
placeholder="Search..."
style={{ borderWidth: 1, padding: 12, borderRadius: 8 }}
/>
<Text>Committed query: {committedQuery}</Text>
</View>
);
}The key move is splitting state into two pieces: inputValue tracks what the user is seeing live, and committedQuery is what the API cares about. The visible text updates freely during IME composition, but the expensive side effect waits for a pause.
This isn't a perfect fix, but it's a small diff on top of Rork's generated code and solves the most painful symptom — runaway API calls — in nearly every case.
Fix 2: Submit-only search
If live search isn't essential to your product, consider only firing the search when the user presses Enter. Counterintuitively, this often produces a cleaner UX, because the action matches the user's intent.
import { useState } from "react";
import { TextInput, View, Text } from "react-native";
export default function SubmitBasedSearchBox() {
const [inputValue, setInputValue] = useState("");
const [results, setResults] = useState<string[]>([]);
const handleSubmit = () => {
// By this point, IME composition is guaranteed complete
console.log("Search triggered:", inputValue);
setResults([`${inputValue} — result 1`, `${inputValue} — result 2`]);
};
return (
<View style={{ padding: 24 }}>
<TextInput
value={inputValue}
onChangeText={setInputValue}
onSubmitEditing={handleSubmit}
returnKeyType="search"
placeholder="Type and press Enter"
style={{ borderWidth: 1, padding: 12, borderRadius: 8 }}
/>
{results.map((r, i) => (
<Text key={i}>{r}</Text>
))}
</View>
);
}onSubmitEditing fires only when the user presses the return / search key, long after composition finishes. Setting returnKeyType="search" surfaces a magnifying-glass icon on the keyboard, cueing users that this is the moment they run a search. After switching to this pattern in a production app, the "Japanese input broken" complaints completely stopped for me.
Fix 3: Explicit save button for multiline input
For multiline TextInput (notes, journals), a different issue crops up: the "confirm conversion" and "insert newline" keystrokes share the same physical key. Trying to wire "Enter = save" almost always creates accidental conflicts with IME confirmation.
The pragmatic solution is to abandon key-based saving altogether and make the user tap an explicit save button.
import { useState } from "react";
import { TextInput, View, Text, Pressable } from "react-native";
export default function MultilineMemoEditor() {
const [memo, setMemo] = useState("");
const [saved, setSaved] = useState(false);
const handleSave = () => {
console.log("Memo saved:", memo);
setSaved(true);
};
return (
<View style={{ padding: 24 }}>
<TextInput
value={memo}
onChangeText={(t) => {
setMemo(t);
setSaved(false);
}}
multiline
placeholder="Write a memo..."
style={{
borderWidth: 1,
padding: 12,
borderRadius: 8,
minHeight: 120,
textAlignVertical: "top",
}}
/>
<Pressable
onPress={handleSave}
style={{
marginTop: 16,
padding: 12,
backgroundColor: "#0066cc",
borderRadius: 8,
}}
>
<Text style={{ color: "white", textAlign: "center" }}>Save</Text>
</Pressable>
{saved && <Text style={{ marginTop: 8 }}>Saved</Text>}
</View>
);
}Yes, it's tempting to let Enter save. But because Enter also confirms Japanese conversion, you will inevitably create false saves or dropped newlines. Explicit actions are friendlier to every input method, not just Japanese.
Prompting Rork for IME-aware code
When you're starting a new Rork project and know your app will be used in Japanese (or Chinese, Korean), bake the constraint into your prompt. I append something like this to any screen that contains text input:
- "Assume users type with a Japanese IME. On any TextInput, use debounce or
onSubmitEditingso side effects don't fire during composition." - "For search boxes, don't call the API on
onChangeText. UsereturnKeyType=\"search\"and fire the search inonSubmitEditingonly." - "For multiline TextInput, do not bind save or submit to the Enter key. Use an explicit save button."
That one paragraph prevents a whole class of post-launch bugs. If you want a deeper look at how to structure prompts for Rork, my Rork Prompt Engineering Mastery Guide goes into this in more detail.
Three less obvious IME gotchas
A few more pitfalls that surprised me during production testing:
maxLengthcounts preedit characters. AmaxLength={10}input can block the user from confirming their conversion because the interim hiragana string (say 7 characters) plus more input exceeds the limit before it collapses to 3 kanji. Leave a little slack, or drop the limit entirely.- Emoji grapheme clusters. Family emoji (👨👩👧👦) are multi-codepoint sequences.
text.lengthmisreports them.[...text].lengthgets closer to the user's perception. - Full-width vs half-width for email/password. Without
autoCapitalize="none"andkeyboardType="email-address", iOS users can accidentally type full-width (zenkaku) characters into credential fields and get baffling auth errors.
A concrete next step
Pick one screen in your current Rork project that uses a TextInput and check whether onChangeText is wired directly to an API call or a heavy state update. If it is, swap it for the debounce or onSubmitEditing pattern from above. The perceived quality jump for Japanese users is immediate and obvious.
For adjacent improvements, Rork form validation with react-hook-form and Zod is a great next read, and fixing the keyboard-covering-input problem in Rork apps tackles the other common text-input pain point that tends to come up around the same time.