skip to content

In a React Native checkout, uppercasing the postcode inside a controlled TextInput's onChangeText makes typed characters flicker. Why does this happen, and how do you fix it?

level: seniorimportance: should knowfreq 30%

answer

  1. the native field applies the keystroke first
  2. JavaScript answers after a render
  3. the correction is a second native update
  4. stale corrections are dropped by eventCount
  5. let native props do the work

basics

~20 s

The native field shows each keystroke immediately; the uppercased value comes back from JavaScript only after a render, so the raw character appears and is then replaced. Prefer native props such as autoCapitalize="characters" and maxLength, and normalise the value on blur or submit.

solid answer

~40 s

In React Native the platform text field owns the text and applies each keystroke at once. The change event then goes to JavaScript, `onChangeText` sets uppercased state, React renders, and only then does `TextInput` see that `value` differs from what the native field last reported and push the corrected text down. For a moment the user sees the lowercase character, then it swaps, and the cursor can move. The push carries the `eventCount` it was based on, and the native side ignores it if the user has typed since, so under fast typing some corrections are skipped until the next keystroke. The docs recommend avoiding JavaScript rewrites: use `maxLength` instead of slicing, `editable={false}` instead of re-setting the same value to block edits, `autoCapitalize="characters"` for case, and normalise on `onEndEditing` or at submit.

code

tsx · 21 lines
tsx
import {useState} from 'react';
import {TextInput} from 'react-native';

// Flickers: each keystroke is drawn lowercase, then replaced after a render.
export function PostcodeFlickers() {
  const [postcode, setPostcode] = useState('');
  return <TextInput value={postcode} onChangeText={t => setPostcode(t.toUpperCase())} />;
}

// Native props do the per-keystroke work; normalise once when editing ends.
export function Postcode({onDone}: {onDone: (p: string) => void}) {
  return (
    <TextInput
      autoCapitalize="characters"
      autoCorrect={false}
      maxLength={8}
      autoComplete="postal-code"
      onEndEditing={e => onDone(e.nativeEvent.text.trim().toUpperCase())}
    />
  );
}

go deeper

for a junior

Recall that rewriting text in onChangeText can flicker, and that props like maxLength and autoCapitalize handle common cases natively.

for a middle

Explain the order of events: native insert, change event, state update, render, then the native text being set again from value.

for a senior

Diagnose flicker and skipped corrections through the eventCount handshake, and move transformation to native props and blur-time normalisation without losing validation.

for a principal

Set team guidance on controlled inputs versus native constraints and boundary normalisation, and on when a native input-mask dependency is worth its maintenance cost.

## The symptom A grocery checkout wants postcodes in capitals. The obvious code is a controlled `TextInput` whose `onChangeText` calls `setPostcode(text.toUpperCase())`. On a device, typing `sw1a` shows each lowercase letter for a moment before it flips to a capital, sometimes with the cursor jumping. On a busy screen, some letters stay lowercase until the next keystroke. ## Why: two writers, one field A React Native `TextInput` is backed by a real platform text field, and that field **owns the text on screen**. A controlled input has two writers: 1. The user types `a`. The **native field** inserts it immediately and draws `SW1a`. 2. The field emits a change event with the new text and an **`eventCount`** that it increments on every change. 3. JavaScript runs `onChange` and `onChangeText`; your handler sets state to `SW1A`. 4. React re-renders. `TextInput` compares the new `value` with the last text the native field reported (`SW1a`), sees they differ, and sends a command to set the native text and selection to `SW1A`, tagged with the `eventCount` it saw. 5. The native field applies the command **only if its own event count still matches**. If the user has typed another character in the meantime, the command is stale and is ignored so it cannot erase newer input. Between steps 1 and 5 the lowercase letter is visible: that is the flicker. Replacing the text can also reset the selection, which is the cursor jump. Step 5 explains the "sometimes it doesn't uppercase" reports under fast typing. The heavier the render in step 4, the longer the gap. The docs describe the same trap for **rejecting** input: keeping `value` unchanged to block an edit makes the native field show the edit and then snap back. ## Fixes, from best to acceptable | Goal | Instead of JavaScript rewriting | Use | |---|---|---| | Capital letters | `toUpperCase()` on every keystroke | `autoCapitalize="characters"`, then normalise on blur or submit | | Length limit | `text.slice(0, 8)` in `onChangeText` | `maxLength={8}`, enforced natively | | Block editing | re-setting the old `value` | `editable={false}` (or `readOnly`) | | Formatting (spaces) | inserting spaces per keystroke | format on `onEndEditing`, or show a formatted read-only preview | - **`autoCapitalize="characters"`** sets the keyboard to capitals, so most users type capitals in the first place. It is a keyboard setting, not a guarantee: users can turn shift off, and paste or autofill bypass it. So still **normalise** with `toUpperCase()` in `onEndEditing` or when submitting. - **`maxLength`** stops extra characters in the native field; the docs specifically recommend it over JavaScript logic to avoid flicker. - **Keep the controlled render cheap.** If the form must stay controlled, avoid re-rendering the whole checkout on each keystroke; state for one field should re-render one field. ## When per-keystroke transformation is unavoidable Some inputs, such as card numbers with grouping, genuinely need formatting while typing. Options, with their costs: 1. Accept a brief flicker and keep the transform minimal and fast. 2. Store the raw value and render the formatted version elsewhere, keeping the field itself untransformed. 3. Use an input-mask component that formats on the native side, if the project is willing to take on a native dependency. ## Reviewing a form for this bug When reviewing a React Native form, look for these patterns in `onChangeText` handlers: - `toUpperCase()`, `toLowerCase()` or `replace(...)` applied before setting state; - `slice(...)` or conditional `return` that keeps the old value to reject input; - formatting that inserts separators while typing; - one form-level state object whose update re-renders every field on each keystroke. Each can usually move to a native prop, to `onEndEditing`, or to submit-time normalisation. Where it cannot, keep the transform small and the render narrow. ## What to take into the interview - The native field is the source of what is on screen; `value` is a correction sent after a render. - The `eventCount` handshake prevents stale overwrites, which is also why corrections can be skipped. - Prefer native props (`autoCapitalize`, `maxLength`, `editable`) and normalise at the boundaries (`onEndEditing`, submit).

  • In React Native, why do the docs recommend maxLength over trimming the text in onChangeText?
    `maxLength` is enforced by the native field, so the extra character is never inserted. Trimming in `onChangeText` lets the native field show the character first, then removes it when the corrected `value` is pushed back after a render, which is visible as flicker. The same reasoning is why the docs suggest `editable={false}` rather than re-setting an unchanged `value` to block edits.
  • In React Native, why can a controlled TextInput's correction be skipped when the user types quickly?
    `TextInput` tags the correction with the `eventCount` of the change it was based on. If the user types again before it arrives, the native field's count has moved on and it ignores the stale command, so newer typing is never overwritten. The next change event triggers a fresh correction, which is why fast typing can leave a character briefly untransformed.

It is like a stenographer who writes each word as it is spoken while an editor down the hall sends back corrections: the page first shows what was said, then the edit arrives, and an edit for a line that has since been rewritten is thrown away.

saying these in an interview costs you the question

  • In a controlled TextInput, the native field waits for JavaScript before drawing a keystroke.
  • Uppercasing in onChangeText is the recommended way to force capitals.
  • autoCapitalize="characters" guarantees the value is uppercase.
  • Re-setting the same value is the right way to block edits in a TextInput.
  • The flicker is caused by React Native re-creating the native field on every render.