A user once left a one-star review on a Rork app of mine that simply said: "Typing feels broken — characters drop randomly." It was the kind of feedback that's painful precisely because the bug was real, but I had never been able to reproduce it on my own machine. After shipping more than thirty apps with Rork, I've come to recognize this class of TextInput bug almost instantly: characters vanish for a frame, the cursor leaps to the end of the field, or the IME on iOS quietly swallows whatever was being typed.
The cause is almost always one of three patterns in how state is being managed around the input. Once you know the patterns, the fix usually takes a few minutes. This article walks through each one with the exact code changes that resolve it, in the order I suggest you check them when triaging a real report.
Why Characters Disappear or Cursors Jump
A controlled TextInput in React Native runs a small loop on every keystroke: the user types, onChangeText fires, you call useState to capture the value, React re-renders, and the new value is written back to the native text field. Anything that causes that loop to drop a frame, write back a stale value, or fire twice in quick succession will surface as a missing character or a misplaced cursor.
The bugs are far more common in non-Latin input flows — Japanese, Korean, Chinese — because those IMEs hold characters in a "composing" state for several keystrokes before committing them. Rork tends to generate code that assumes English-style instant commits, which means problems that look fine in English testing surface immediately when a Japanese user types in your app. The patterns below apply universally, but you'll see them most often in stories like that.
Pattern 1: Derived state in a separate variable triggers extra renders
The most common offender is a screen that updates an unrelated piece of state on every keystroke, forcing two renders for what should be one.
// ❌ Bad — setText and setCount cause two state updates per keystroke,
// and the second one can clobber an in-progress IME composition
function NoteEditor() {
const [text, setText] = useState('');
const [count, setCount] = useState(0);
return (
<View>
<Text>{count} / 200</Text>
<TextInput
value={text}
onChangeText={(t) => {
setText(t);
setCount(t.length); // ← the second update is the problem
}}
multiline
/>
</View>
);
}React generally batches these updates, but composition state from an IME can slip between them, and the second setCount writes a render that effectively discards the most recent character. Rork often produces this shape when you ask it for a "text area with a character counter."
// ✅ Good — keep state in one place, derive the count at render time
function NoteEditor() {
const [text, setText] = useState('');
return (
<View>
<Text>{text.length} / 200</Text>
<TextInput
value={text}
onChangeText={setText}
multiline
/>
</View>
);
}Derived values — character counts, validation results, length-based styles — should be computed during rendering, or memoized with useMemo only if the calculation is genuinely expensive. Treating useState like a cache for things you can recompute is what causes most of the unnecessary renders that lead to TextInput weirdness.
Pattern 2: A parent value arrives late and overwrites typing
The second pattern shows up when a TextInput receives value from a parent, and the parent's update lags behind the user's typing.
// ❌ Bad — onChangeText awaits the network round trip
function ProfileForm({ initialName, onSave }: Props) {
const [name, setName] = useState(initialName);
const handleChange = async (t: string) => {
setName(t);
await onSave(t); // ← the stale name written back here can overwrite live typing
};
return <TextInput value={name} onChangeText={handleChange} />;
}If the user is partway through typing "Alexander" when the API call for "Alex" returns, the stored value in the parent is now "Alex" and gets pushed back into the field, erasing whatever was typed in the meantime. With a Japanese IME, the still-composing characters get dropped along with the committed ones.
// ✅ Good — separate live input from persistence, debounce the save
function ProfileForm({ initialName, onSave }: Props) {
const [name, setName] = useState(initialName);
useEffect(() => {
const id = setTimeout(() => {
if (name !== initialName) onSave(name);
}, 500);
return () => clearTimeout(id);
}, [name]);
return <TextInput value={name} onChangeText={setName} />;
}Pattern 3: Transforming text inside onChangeText creates a feedback loop
The third pattern is rarer but eats half a day of debugging when you hit it. It happens when onChangeText rewrites the text before storing it.
// ❌ Bad — uppercases on every keystroke, fights the IME
<TextInput
value={text}
onChangeText={(t) => setText(t.toUpperCase())}
/>In English this looks fine. In Japanese, applying a transformation while the IME is still composing characters corrupts the composition buffer: the candidate window flickers shut, partial conversions get committed in romaji, and the user feels like the field is fighting them. The same thing happens with auto-capitalizing first letters, normalizing half-width to full-width katakana, or stripping characters with a regex.
// ✅ Good — let the field hold raw input, transform after commit
<TextInput
value={text}
onChangeText={setText}
onEndEditing={(e) => setText(e.nativeEvent.text.toUpperCase())}
/>Apply formatting on onEndEditing, onBlur, or in the submit handler — anywhere except mid-keystroke. "Real-time formatting in onChangeText is almost always a bad idea" is one of those rules that pays for itself the first time you save it.
A Triage Order That Always Works
When a real bug report lands and I can't reproduce it locally, I run through the three patterns in this order:
- Drop a
console.log('render')at the top of the screen component and count renders per keystroke. Three or more usually means Pattern 1. - If
valueis coming from a parent, audit the parent's update path for awaited work or stale closures inuseEffect. That's Pattern 2. - Strip
onChangeTextdown to plainsetTextand see if the symptom disappears. If it does, Pattern 3 was hiding in the transformation.
The reproduction environment that actually shows these bugs reliably is an iOS device with the system Japanese keyboard. The simulator with an English layout often hides the symptoms entirely, and Android emulators have different input latency that can mask Pattern 1. Don't skip the device test step.
Other Symptoms That Belong to the Same Family
A few less obvious symptoms share roots with the patterns above and respond to the same fixes.
- The cursor snaps to the end of the field after every keystroke. This is usually Pattern 2 — the parent's
valueis being recomputed (often viaJSON.parse(JSON.stringify(...))or a normalizer), creating a new string identity that resets selection. - The keyboard's autocorrect suggestion bar flickers or empties out unexpectedly. That tends to be Pattern 1 — extra renders are forcing the keyboard to re-attach its suggestion provider every couple of frames.
- A multiline
TextInput"stutters" while the user types fast. Almost always Pattern 1, with the offending state update being something like a layout measurement stored in state.
When a teammate reports any of these, I open their screen file and look for the same three things: how many useState calls live next to the input, whether the parent passes value down through a function, and whether onChangeText does anything other than setText. That short checklist catches the actual bug roughly nine times out of ten, often before I even need to run the app on a device, which saves a surprising amount of time over the course of a release cycle.
Closing Thoughts
TextInput bugs are frustrating because they live in the gap between "the code looks correct" and "users feel something's off." Rork generates good code most of the time, but adding state, async, or formatting on top of a generated component is exactly when one of these three patterns sneaks in. The triage order — re-renders, parent lag, then mid-keystroke transforms — narrows the cause down within minutes. Once your TextInputs feel quiet on a Japanese device, ship the build with a little more confidence than you had this morning. If you're also dealing with IME composition events firing too often, the companion piece on Japanese IME issues in Rork TextInputs covers that adjacent failure mode.