The one thing to get right first: state is a string
Nearly every broken number input in React Native has the same shape: the component stores Number(text) in state and passes that number back as value. It works until the user types something that is not yet a number. “3.” becomes 3 and the dot disappears. “-” becomes NaN and the field shows “NaN”. Clearing the field becomes 0 and the user cannot delete the zero.
The fix is structural, not a patch. The input owns a string. Parsing happens somewhere else, when you need the value.
import { useState } from 'react';
import { TextInput } from 'react-native';
export function QuantityInput({ onChange }: { onChange: (n: number | null) => void }) {
// The input owns a STRING. Always.
const [text, setText] = useState('');
const handleChange = (raw: string) => {
// Enforce digits here. keyboardType does not block paste or hardware keys.
const digits = raw.replace(/[^0-9]/g, '');
setText(digits);
onChange(digits === '' ? null : Number(digits));
};
return (
<TextInput
value={text}
onChangeText={handleChange}
keyboardType="number-pad"
inputMode="numeric"
maxLength={6}
placeholder="0"
accessibilityLabel="Quantity"
/>
);
}Two props do the keyboard work. keyboardType picks the native keyboard on iOS and Android. inputMode is the newer, web-aligned prop that does the same job and also drives the virtual keyboard on React Native Web. Set both; they do not conflict.
Which keyboardType, on which platform
The values are the same on both platforms. What they show is not, and the differences are exactly where bugs hide: iOS number pads have no minus key and no return key.
| keyboardType | iOS | Android | Use it for |
|---|---|---|---|
number-pad | Digits only. No decimal, no minus, no return key | Phone-style digits. Return key present | Quantities, PINs, years, zip codes |
decimal-pad | Digits plus the locale decimal separator. No minus, no return key | Digits plus decimal separator | Prices, weights, measurements |
numeric | Digits with a punctuation row (. , - and more). Return key present | Same as number-pad | Values that may be negative on iOS |
numbers-and-punctuation | Full numeric and symbol layout. Return key present | Falls back to the default keyboard | Coordinates, ranges like 10-20, iOS-only apps |
phone-pad | Digits plus * and # | Phone dialer layout | Phone numbers only |
Default rule: number-pad for whole numbers, decimal-pad for anything with a fraction. Reach for numeric only when iOS users need a minus sign, and even then consider a separate +/- toggle, because a punctuation row next to digits invites typos.
Decimals: the comma problem
decimal-pad shows the decimal separator for the device locale. A German or Brazilian user gets a comma. If your parser only accepts a dot, Number("12,50") is NaN and the field looks broken in exactly the markets you did not test in. Normalise before parsing, and allow at most one separator:
export function sanitizeDecimal(raw: string, maxDecimals = 2) {
// Accept either separator while typing, keep what the user typed on screen.
let s = raw.replace(/[^0-9.,]/g, '');
// Keep only the first separator.
const firstSep = s.search(/[.,]/);
if (firstSep !== -1) {
const head = s.slice(0, firstSep + 1);
const tail = s.slice(firstSep + 1).replace(/[.,]/g, '').slice(0, maxDecimals);
s = head + tail;
}
return s;
}
export function parseDecimal(text: string): number | null {
if (text === '' || text === '.' || text === ',') return null;
const n = Number(text.replace(',', '.'));
return Number.isFinite(n) ? n : null;
}Keep what the user typed in the field, normalise only in parseDecimal. Silently swapping their comma for a dot mid-keystroke reads as a glitch, and some Android keyboards will then refuse to insert a second comma.
Currency input that formats as you type
The classic request: the user types 1250 and sees $12.50, then 12500 and sees $125.00. The classic failure: the cursor jumps to the wrong place after every keystroke, or the formatted string gets parsed back and loses a digit. Both come from treating the formatted display as the source of truth.
Store cents as a digit string. Format on render. Never parse the formatted string.
import { useMemo, useState } from 'react';
import { TextInput } from 'react-native';
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export function CurrencyInput({ onChange }: { onChange: (cents: number) => void }) {
// Source of truth: digits only, representing cents. "1250" => $12.50
const [cents, setCents] = useState('');
const display = useMemo(() => {
if (cents === '') return '';
return formatter.format(Number(cents) / 100);
}, [cents]);
const handleChange = (raw: string) => {
// Whatever the user typed INTO the formatted string, we only care about digits.
const digits = raw.replace(/[^0-9]/g, '').replace(/^0+(?=\d)/, '').slice(0, 12);
setCents(digits);
onChange(digits === '' ? 0 : Number(digits));
};
return (
<TextInput
value={display}
onChangeText={handleChange}
keyboardType="number-pad"
inputMode="numeric"
placeholder={formatter.format(0)}
// Prevents iOS from offering to autofill a credit card number here.
textContentType="none"
accessibilityLabel="Amount in US dollars"
/>
);
}This works because money grows from the right. Every keystroke appends a digit, the formatted value gets longer on the right, and the cursor is always at the end. Backspace removes the last digit, the string shrinks, same story. The one real limitation: the user cannot place the cursor in the middle and edit a specific digit. For most payment and amount fields that is the behaviour people expect from an ATM-style input anyway.
If you genuinely need in-place editing of a formatted number (phone numbers, IBANs, card numbers), that is a mask problem, not a number-input problem. react-native-mask-input handles cursor tracking inside a mask, and react-native-currency-input wraps the same idea specifically for money with locale separators. Both are pure JavaScript and run in Expo Go.
The iOS number pad has no Done key
This one generates support tickets. number-pad and decimal-pad on iOS have no return key at all, so returnKeyType="done" and onSubmitEditing never fire. If the field is the last thing on the screen, the keyboard covers your button and the user is stuck.
Two fixes. The cheap one is a tap-outside dismiss:
import { Keyboard, Pressable } from 'react-native';
<Pressable style={{ flex: 1 }} onPress={Keyboard.dismiss} accessible={false}>
{/* screen content */}
</Pressable>The proper one is a toolbar above the keyboard. InputAccessoryView is the iOS-only core component for exactly this:
import { Button, InputAccessoryView, Keyboard, Platform, TextInput, View } from 'react-native';
const ACCESSORY_ID = 'amount-done';
<TextInput
keyboardType="decimal-pad"
inputAccessoryViewID={Platform.OS === 'ios' ? ACCESSORY_ID : undefined}
/* ...value, onChangeText */
/>
{Platform.OS === 'ios' && (
<InputAccessoryView nativeID={ACCESSORY_ID}>
<View style={{ alignItems: 'flex-end', backgroundColor: '#f2f2f7', padding: 6 }}>
<Button title="Done" onPress={Keyboard.dismiss} />
</View>
</InputAccessoryView>
)}Android keeps its return key on every numeric keyboard, so the guard is harmless there. If you already use react-native-keyboard-controller, its KeyboardToolbar gives you the Done button plus next and previous field arrows on both platforms with no per-field wiring.
Negative numbers and a stepper
Since the iOS number pads have no minus key, the honest options are a punctuation keyboard (ugly) or a sign toggle next to the field (clear). For small ranges, a stepper with plus and minus buttons beats a keyboard entirely, and it is the pattern that gets the accessibility story right for free:
import { Pressable, Text, TextInput, View } from 'react-native';
type Props = { value: number; min?: number; max?: number; step?: number; onChange: (n: number) => void };
export function Stepper({ value, min = -Infinity, max = Infinity, step = 1, onChange }: Props) {
const clamp = (n: number) => Math.min(max, Math.max(min, n));
return (
<View
style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}
accessibilityRole="adjustable"
accessibilityLabel="Quantity"
accessibilityValue={{ now: value, min: Number.isFinite(min) ? min : undefined, max: Number.isFinite(max) ? max : undefined }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(e) => {
if (e.nativeEvent.actionName === 'increment') onChange(clamp(value + step));
if (e.nativeEvent.actionName === 'decrement') onChange(clamp(value - step));
}}
>
<Pressable onPress={() => onChange(clamp(value - step))} disabled={value <= min} hitSlop={8}>
<Text style={{ fontSize: 22, width: 32, textAlign: 'center' }}>-</Text>
</Pressable>
<TextInput
value={String(value)}
onChangeText={(t) => {
const n = Number(t.replace(/[^0-9-]/g, ''));
if (Number.isFinite(n)) onChange(clamp(n));
}}
keyboardType="numeric"
inputMode="numeric"
style={{ minWidth: 48, textAlign: 'center' }}
/>
<Pressable onPress={() => onChange(clamp(value + step))} disabled={value >= max} hitSlop={8}>
<Text style={{ fontSize: 22, width: 32, textAlign: 'center' }}>+</Text>
</Pressable>
</View>
);
}accessibilityRole="adjustable" is the part most steppers skip. It lets VoiceOver and TalkBack users swipe up and down to change the value instead of hunting for two tiny buttons, which is the same gesture they use on every native slider and stepper.
Four small things that bite later
- maxLength counts characters, not digits. On a formatted currency field, the dollar sign and separators eat into it. Enforce length on the raw digits in
onChangeText, not with the prop. - Leading zeros.“007” parses to 7, which is fine for a quantity and wrong for a PIN or a zip code. Decide per field whether the string or the number is the real value.
- Float precision. 0.1 + 0.2 is still 0.30000000000000004 in JavaScript. Store money as integer cents, as the currency example does, and only divide for display.
- Autofill on iOS.A bare numeric field near words like “card” or “code” can trigger the credit card or one-time-code autofill bar. Set
textContentType="none"on fields that are not those things, andtextContentType="oneTimeCode"on the field that is. The OTP input guide covers that case end to end.
Skip the wiring
A string-backed input, locale-safe decimal parsing, a currency field that keeps its cursor, a Done bar on iOS. None of it is hard, all of it is the kind of thing you rewrite in every app and get slightly wrong once. ShipNative generates a real Expo app from a description with these form patterns already in place, previews it on your phone, and hands you the full React Native project to keep editing. Describe the form you need and change the number field like any other file you wrote.