Skip to content
Native Animation

React Native Number Input: Decimals, Limits, and Steppers

By Native Animation · Updated

A React Native number input is a TextInput that keeps an editable string separately from the accepted number. A numeric keyboard helps entry but does not validate pasted text, decimal precision, or range limits. Use a field for arbitrary values, a stepper for small quantity changes, and a keypad for focused amount entry.

Choose a number field, keypad, or stepper

TaskControlWho owns the accepted value?
Enter a decimal or paste a valueTextInput with commit validationThe form parent
Add or remove a few itemsQuantity StepperThe cart or inventory host
Compose a dollar amountAmount Entry KeypadThe host's transaction flow
Select a bounded discrete scaleRuler picker or wheel pickerThe setting's parent

This tutorial owns number editing and commit behavior. For general labels, focus, and validation presentation, see the text input guide.

Keep the editing string separate from the accepted number

Empty input and an unfinished decimal

The string 1. expresses editing intent that the number 1 cannot retain. Empty input is also different from zero. Do not run Number on every keystroke or replace the draft with a formatted number while someone is typing.

Editing sequenceDraftAccepted value before Apply
Clear the fieldempty stringUnchanged
Enter a digit1Unchanged
Start a decimal1.Unchanged
Add a fraction1.5Unchanged
Tap Apply1.51.5 if accepted
Enter 1001 and Apply1001 remains visibleUnchanged; maximum error

Paste, signs, separators, and range limits

The complete example below accepts nonnegative U.S. decimal notation with at most two decimal places and a demo range of 0–1000. It intentionally rejects currency symbols, signs, commas, exponent notation, and an unfinished decimal on commit. It keeps those drafts visible with a corrective message rather than guessing what the person meant. To accept localized paste, choose a locale-aware parser and document its separators; replacing every comma can silently turn a grouped amount into a fraction.

Commit and validate without cursor jumps

Use this complete NumberInputExample.tsx outside the Expo Router route directory. It uses React Native 0.88 TextInput and no animation dependency, within Expo SDK 58. See the versioned TextInput reference for inputMode and controlled values.

import { useEffect, useState } from 'react';
import { Button, Text, TextInput, View } from 'react-native';

type CommitResult = { accepted: true } | { accepted: false; message: string };
type NumberFieldProps = {
  value: number | null;
  onCommit: (next: number | null) => CommitResult;
};

export function parseDraft(draft: string):
  { ok: true; value: number | null } | { ok: false; message: string } {
  if (draft === '') return { ok: true, value: null };
  if (!/^\d+(?:\.\d{1,2})?$/.test(draft)) {
    return { ok: false, message: 'Enter a number with up to two decimal places.' };
  }
  const value = Number(draft);
  if (!Number.isFinite(value) || value < 0 || value > 1000) {
    return { ok: false, message: 'Enter a value from 0 to 1000.' };
  }
  return { ok: true, value };
}

export function NumberField({ value, onCommit }: NumberFieldProps) {
  const [draft, setDraft] = useState(value === null ? '' : String(value));
  const [error, setError] = useState('');
  useEffect(() => {
    setDraft(value === null ? '' : String(value));
    setError('');
  }, [value]);
  function apply() {
    const parsed = parseDraft(draft);
    if (!parsed.ok) { setError(parsed.message); return; }
    const result = onCommit(parsed.value);
    setError(result.accepted ? '' : result.message);
  }
  return (
    <View style={{ gap: 12 }}>
      <Text>Weight (lb, optional)</Text>
      <TextInput value={draft} inputMode="decimal"
        accessibilityLabel="Weight in pounds, optional"
        accessibilityHint="Apply to save. Up to two decimal places, from 0 to 1000."
        onChangeText={(text) => { setDraft(text); setError(''); }}
        onSubmitEditing={apply}
        style={{ minHeight: 48, borderWidth: 1, borderColor: '#655475', padding: 12 }} />
      {error !== '' && <Text accessibilityRole="alert">{error}</Text>}
      <Button title="Apply weight" onPress={apply} />
      <Button title="Revert draft" onPress={() => {
        setDraft(value === null ? '' : String(value)); setError('');
      }} />
    </View>
  );
}

export function NumberInputExample() {
  const [weight, setWeight] = useState<number | null>(null);
  return (
    <View style={{ padding: 24, gap: 16 }}>
      <NumberField value={weight} onCommit={(next) => {
        if (next !== null && next > 500) {
          return { accepted: false, message: 'This shipping service accepts up to 500 lb.' };
        }
        setWeight(next);
        return { accepted: true };
      }} />
      <Text>Saved weight: {weight === null ? 'Not set' : weight + ' lb'}</Text>
    </View>
  );
}

Report errors without discarding the draft

Apply is explicit because decimal keyboards do not always offer a return key. Invalid Apply leaves the exact text in place; Revert restores the accepted value even when that value has not changed. No controlled selection prop moves the caret, and no automatic blur is required to display an error.

Accept or reject a controlled value

The synchronous onCommit contract returns acceptance or a host error. Enter 501: field syntax passes but the demo shipping rule rejects it. The saved weight stays unchanged. For a server decision, add an explicit pending state and request identity; ignore obsolete responses and keep the draft on rejection. Do not present this synchronous example as remote validation.

An external value change replaces the draft by policy. If a reset needs to clear a draft while the accepted number stays the same, call the example's Revert action or change a host-owned reset key. Avoid an effect that rewrites the draft on every render.

Use a stepper for precise changes

Quantity Stepper is useful for whole-item counts and repeated increment/decrement. After adding the piece through the CLI, its controlled props are:

import { useState } from 'react';
import { QuantityStepper } from './quantity-stepper';

export function CartQuantity() {
  const [quantity, setQuantity] = useState(0);
  return <QuantityStepper value={quantity} onChange={setQuantity}
    min={0} max={12} repeatOnHold
    addAccessibilityLabel="Add notebook"
    incrementAccessibilityLabel="Increase notebook quantity"
    decrementAccessibilityLabel="Decrease notebook quantity" />;
}

The component clamps quantity requests, supports hold repeat, and collapses at its minimum. Its ref exposes increment, decrement, setValue, and reset. It does not expose a decimal step-size prop; choose a field for arbitrary fractional editing. The host still validates stock and price at checkout.

Use a keypad for focused amount entry

Amount Entry Keypad retains amount text, animates digits and separators, and supplies a local confirmation. Its initialAmount is an initial string; it is not a controlled value prop. onAmountChange reports text, while onSend(amount, note) runs after visual confirmation. It accepts up to six integer digits and two fractional digits. Read the delivered README for its Skia, Expo Blur, gradient, Reanimated, and Worklets setup; web also needs CanvasKit.

A visual confirmation does not authorize or transfer funds. Connect it to a host workflow whose copy accurately describes the actual transaction state, and enforce balance and authorization on the server. Use a regular field when custom keypad space or the confirmation choreography does not fit the task.

Accessibility and formatting

Label the unit and optional/required status visibly and for assistive technology. Expose the saved number as one value, use a readable error message, and give stepper actions distinct names. Do not announce each decorative rolling digit. Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release.

U.S. demo formats and custom formatters

The field edits 1.5 and displays pounds. A money summary may format an accepted amount with Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) outside the editor. Keep integer cents or an appropriate decimal representation in financial storage; a JavaScript floating-point number is not an exact accounting model. Custom formatting belongs at the display boundary, not in an uncontrolled caret-rewriting loop.

FAQ

Does a decimal keyboard validate the number?

No. It suggests keys. Hardware keyboards, paste, accessibility input, and platform differences still require validation.

Should clearing the field save zero?

Only if your product explicitly defines that behavior. The example treats empty as null and zero as a real value.

Why keep 1. as text?

It preserves the person's next editing step. This example asks for a completed fraction on Apply without deleting the draft.

Can the parent reject a quantity change?

Yes. Keep the controlled value unchanged and show a useful reason. Clamping in a UI control does not replace inventory or server validation.