React Native Phone Number Input: Country Search and E.164
By Native Animation · Updated
A React Native phone number input should keep the selected country, national digits, formatted display text, and cursor selection as separate values. This tutorial builds that contract with Expo SDK 58 and React Native 0.88, then lets the host validate the number and submit an E.164 value. Country search, formatting, errors, keyboard hints, and accessibility remain predictable because the input never guesses a global numbering rule.
Separate country choice, national digits, and submission
A phone field has at least three identities: a country record such as US, the digits the person typed, and the canonical value your backend accepts. Keep them separate instead of storing one string that mixes +1, spaces, and a national number. A country change can then preserve the national digits, while a validator decides whether those digits are valid for that country.
The host owns the country data, formatting policy, validation adapter, external error, and request lifecycle. A reusable field can normalize digit input and report selection metadata, but it cannot know whether a number is assigned, portable, or allowed by your product. Treat the number as a string throughout so leading zeros survive.
Build a controlled phone field with a country prefix
Use a country record with a stable ISO identity
The catalog component accepts a readonly array of PhoneCountry records. Each record has iso2, name, and a plus-prefixed dialCode; optional searchTerms and disabled support local search and unavailable choices. Duplicate or malformed records are ignored by the component, but the host should still keep its source data clean.
Keep the native TextInput as the cursor owner
Install the SDK-compatible animation package before copying the example:
npx expo install react-native-reanimatedThe complete catalog component is designed for Expo SDK 58, React Native 0.88.0, and Reanimated 4.7.1. Its number field stays mounted when the country sheet opens or when an error appears. That is what lets the native selection, paste path, and keyboard focus continue across visual changes.
Here is a controlled host using the actual public props. The fixture validator is intentionally narrow so the example is testable; replace it with your application's numbering adapter.
import { useState } from 'react';
import {
PhoneCountryInput,
type PhoneCountry,
type PhoneValidation,
type PhoneValue,
} from './phone-country-input';
const countries: readonly PhoneCountry[] = [
{ iso2: 'US', name: 'United States', dialCode: '+1', searchTerms: ['USA'] },
{ iso2: 'CA', name: 'Canada', dialCode: '+1' },
{ iso2: 'GB', name: 'United Kingdom', dialCode: '+44' },
{ iso2: 'JP', name: 'Japan', dialCode: '+81', disabled: true },
];
function validateFixture(digits: string, country: PhoneCountry): PhoneValidation {
if (country.iso2 === 'US' || country.iso2 === 'CA') {
return digits === '2025550148'
? { valid: true, e164: country.dialCode + digits }
: { valid: false, error: 'Enter the 10-digit fixture number.' };
}
if (country.iso2 === 'GB') {
return digits === '7700900148'
? { valid: true, e164: country.dialCode + digits }
: { valid: false, error: 'Enter the 10-digit fixture number.' };
}
return { valid: false, error: 'This demo country is unavailable.' };
}
export function PhoneForm() {
const [value, setValue] = useState<PhoneValue>({
countryIso2: 'US',
nationalNumber: '',
});
const [error, setError] = useState<string>();
const [submitted, setSubmitted] = useState<string>();
return (
<PhoneCountryInput
countries={countries}
value={value}
onChange={(next, meta) => {
setValue(next);
if (meta.reason !== 'reset') setError(undefined);
}}
validateNumber={validateFixture}
error={error}
helperText="Choose a country, then enter your number."
validText="Number accepted."
onSubmit={(next, meta) => {
if (!meta.e164) {
setError('Enter a valid phone number before continuing.');
return;
}
setError(undefined);
setSubmitted(meta.e164);
void submitToServer(next, meta.e164);
}}
labels={{
title: 'Your phone number',
country: 'Country or region',
phone: 'Phone number',
continue: 'Continue',
}}
/>
);
}
async function submitToServer(value: PhoneValue, e164: string) {
// Send e164 and the country identity; never log the raw number.
await fetch('/api/phone', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...value, e164 }),
});
}The onChange callback receives the accepted PhoneValue and PhoneChangeMeta. The metadata includes the formatted text, the current selection, an E.164 value or null, and a reason of edit, country, paste, or reset. Do not treat a non-null value as proof that a server accepted the number; it only reflects the synchronous adapter you supplied.
Format while typing without losing the cursor
Map selection by digit counts
A formatter should transform only presentation. It receives raw digits, the selected country, and a selection whose offsets are in the raw digit string. It returns formatted text plus offsets in that formatted text. The safe algorithm counts digits before each endpoint, inserts separators, and maps those counts back to the new string. A separator should never make the caret jump to the end.
This example groups a local number after the first three digits. It preserves all digits and uses the component's exported model helpers for selection mapping:
import { displaySelection, digits } from './phone-country-input/model';
import type { PhoneCountry, PhoneFormat, PhoneSelection } from './phone-country-input';
function formatNumber(
rawDigits: string,
_country: PhoneCountry,
context: { selection: PhoneSelection },
): PhoneFormat {
const clean = digits(rawDigits);
const text = clean.length > 3 ? clean.slice(0, 3) + ' ' + clean.slice(3) : clean;
return { text, selection: displaySelection(text, context.selection) };
}The component already clamps non-finite or out-of-range offsets. Your formatter still must preserve every digit, including incomplete and unusually long input. Do not impose a length limit in a display formatter unless your product has an explicit policy and a separate error for excess input.
Treat paste and international input as explicit policies
The parseInternational prop is optional. Without it, pasted digits remain under the current country. If your product accepts +44 7700 900148, provide a parser that returns both countryIso2 and nationalNumber; returning only a dial code is ambiguous for shared prefixes such as US and Canada. A parser that throws or returns null falls back to the current country and preserves the digits.
Do not infer a country from the device locale or from the first digit. Locale is a presentation default, not proof of a person's number. Ask explicitly, retain the ISO identity, and send the country with the submission when your backend needs it.
Validate in the host and submit E.164
Keep adapter errors separate from host errors
The component's validateNumber callback is synchronous and returns { valid, error?, e164? }. It can show an adapter error in the reserved helper area and disables Continue when the result is invalid. The error prop is different: it is a persistent host error and takes priority until the host removes it. Use it for server rejection, rate limits, or a number that needs a different account.
Keep the draft after a failed submit. Replacing the value with a server-normalized string can destroy the user's cursor and make correction harder. Clear the host error when a new edit starts, then associate async responses with the submitted E.164 value so a slow response cannot overwrite a newer attempt.
Do not confuse keyboard hints with validation
keyboardType="phone-pad", autoComplete="tel-national", and textContentType="telephoneNumber" describe the input to the platform. They do not enforce digits, country rules, or E.164 output. The component sanitizes decimal digits for its model, but the host still owns numbering policy and server verification.
Use E.164 only at the boundary. It is a canonical submission format such as +12025550148; it is not necessarily the best text to show while editing. The host should reject an empty or invalid adapter result, submit the returned E.164 string, and avoid logging it in analytics.
Add searchable country selection
Country search is a single selection inside the component's contained sheet. The query is independent of the phone value, so typing United does not change the digits. The sheet exposes stable test IDs, a search field, a virtualized country list, a selected radio state, and a close action. Disabled countries remain visible when the current value points at one, but cannot be newly selected.
For a controlled integration, use the real open, onOpenChange, query, and onQueryChange props. Keep these states in the host only when a navigation or analytics requirement needs them; the uncontrolled defaults are enough for most forms. There is no selectAll, arbitrary actions, or global modal prop on this component.
Changing country emits onChange with reason country and keeps the number input mounted. A country change does not prove the number is valid for the new numbering plan; run the adapter again and show its result.
Accessibility and keyboard behavior
The field gives the country control and number input separate labels. The number stays left-to-right inside right-to-left layouts, while the country sheet provides radio selection state and a textual result count. Error and helper text use a polite live region, and error announcements happen when the message changes rather than on every inserted separator.
Keep at least a 44-point target around the country and Continue controls. Do not hide the real TextInput behind a decorative clone. VoiceOver and TalkBack need a focusable native control with an honest label, value, and hint. If your screen has a surrounding form label, use React Native's labeling APIs instead of repeating a long label in every state.
On iOS and Android, the phone keyboard is owned by the native TextInput. The component's handle exposes focus() and blur(); the host decides when to call them. The keyboard submit action invokes onSubmit after the consumer validator passes. A host screen should keep the field above the keyboard with its own layout strategy, test selection and deletion with a hardware keyboard, and let the platform Back action close the country sheet before navigating away.
On web, the contained sheet supports Tab/Shift+Tab, arrow navigation through mounted rows, Enter to choose, and Escape to close. These behaviors still need testing in the browser and with the target screen reader. Do not describe browser behavior as proof of native VoiceOver or TalkBack behavior.
When to use Phone Country Input
Phone Country Input is the catalog implementation when you need a contained country sheet, cursor-aware formatting, optional international paste parsing, synchronous consumer validation, and a host-owned error. Its public contract includes countries, value or defaultValue, onChange, formatNumber, validateNumber, parseInternational, controlled selection/query/open props, labels, theme tokens, motion settings, and an imperative ref with focus, blur, openCountries, closeCountries, setValue, and reset.
It does not ship a phone-number database or a promise that a number can receive SMS. The catalog demo uses fictional fixtures and emoji flags; replace those with your own country data and validation service. Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release.
For generic floating labels, controlled values, and form-level error timing, see React Native Text Input. For the verification code after a successful phone submission, see React Native OTP Input. Those guides own their respective patterns; this page focuses on country identity, formatting, and phone validation boundaries.
Common pitfalls
- Concatenating the prefix into one draft string. Keep
countryIso2andnationalNumberseparate so shared dial codes remain distinguishable. - Formatting by replacing the whole value. Map selection by preceding digit counts; otherwise inserting a separator moves the caret and makes mid-string edits frustrating.
- Validating with length alone. Length differs by country and does not prove assignment. Return E.164 only from a real adapter.
- Treating
erroras internal state. Host errors persist until the host removes them; clear them inonChangeafter a new edit. - Using locale as country proof. Offer an explicit country choice and retain its ISO identity.
- Logging raw phone numbers. Keep analytics and error logs to reason codes or redacted identifiers.
- Replacing a failed draft with server output. Preserve the text and cursor so the user can correct one digit.
For the underlying platform behavior, see the React Native 0.88 TextInput reference, the React Native 0.88 accessibility guide, and the Expo SDK 58 Reanimated reference.
FAQ
How do I add a country code without losing the cursor?
Store the country as an ISO identity and the national digits separately. Format the digits for display, then map each selection endpoint by the number of digits before it. A separator changes display offsets, not logical digit offsets.
Should a phone input validate while typing?
Run a cheap synchronous adapter while editing for immediate format feedback, but do not call a remote verification service on every keystroke. Submit only a valid adapter result and let the host handle asynchronous checks and server errors.
How do I submit an E.164 phone number from React Native?
Provide validateNumber and return an E.164 string only when the adapter accepts the country and digits. In onSubmit, read meta.e164, reject null, and send the accepted string to your server.
Can a phone input support country search and paste?
Yes. The component's contained sheet searches the supplied country records. Add parseInternational when pasted international input should select a country; otherwise pasted digits remain with the current country and are not silently reinterpreted.
Which keyboard and accessibility props should a phone field use?
Use a real labeled TextInput, keyboardType="phone-pad", autoComplete="tel-national", and textContentType="telephoneNumber" where supported. These hints improve platform input but do not replace sanitization, validation, error text, or physical-device accessibility testing.