A few days after release, I found a one-star review on a Rork-built note-taking app: "can't confirm Japanese conversion, buggy." My stomach dropped. The app had run fine on my English keyboard, so I had no idea where to start looking.
Feedback like "the search box breaks when I type Japanese" or "the conversion closes before I can confirm" usually traces back to one root. The generated code never anticipated Input Method Editors (IMEs), and English-only testing can't reveal that.
Below I use the patterns I see most often in Rork output to walk through how React Native's TextInput behaves during IME composition. Each fix is short enough to apply to your project the same day.
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 (iOS keyboards in particular), 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
- On some keyboards and devices,
onSubmitEditingfiring when the user hits Enter to confirm a conversion, not to 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.
Keep what the user sees and what you send outward as two separate pieces of state — every fix below builds on that split.
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.
Don't rewrite the value mid-composition
Debouncing doesn't protect you from one more trap: transforming the text inside onChangeText and feeding the result straight back into value.
// ❌ Trimming or replacing the in-progress string can close the candidate window
<TextInput
value={value}
onChangeText={(t) => setValue(t.replace(/\s/g, "").slice(0, 20))}
/>Do the whitespace stripping and length clamping after confirmation instead. Store what the keyboard hands you as-is while the user types, then tidy it in onEndEditing or when they tap Save. My rule now is simple: leave the visible string alone, and clean up only after it's committed.
Choosing among the three fixes
The choice comes down to whether the screen shows results while you type or only after you decide.
| Fix | Best for | Watch out for |
|---|---|---|
| Debounce (about 300 ms) | Live search that narrows as you type | Adds a delay, and a long conversion can still trigger a search on an intermediate string |
| Submit via onSubmitEditing | Ordinary search where users press the search key | You give up the live-search feel |
| Explicit Save button | Multiline notes and journals | Check button placement against the keyboard on a real device |
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 sometimes block a conversion because the interim hiragana string runs long before it collapses to a few kanji (behavior varies by device and keyboard). Leave some slack and clamp after confirmation.- Emoji grapheme clusters. Emoji like the family sequence (👨👩👧👦) join several code points.
text.lengthmisreports them, and[...text].lengthstill counts code points, not what the user sees. UseIntl.Segmenterwhere it exists, or a grapheme-splitting library where it doesn't. - 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. For Japanese users, the first thing they notice isn't how many features you shipped but whether conversion goes through cleanly. I always check that feel with my own fingers before release.
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.