Text Input and Forms
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Core Components and React Fundamentals
introduced TextInput as one of the handful of Core Components; this page covers it — and the rest of React
Native’s form-building surface — in full: controlled text input, keyboard configuration, focus management,
picking from a closed set of options, date/time selection, keeping the focused field above the keyboard, and
form state/validation with React Hook Form and Zod, ending with Places's own "add place" form. Input
accessibility — accessibilityLabel, accessible grouping, and screen-reader behavior for every control
introduced here — is only briefly mentioned below; Accessibility
(later in this section) is where it is covered in full. The useAddPlace() mutation this page’s form submits
through is only briefly mentioned too; Networking and
Data Fetching (later in this section) covers what that Hook does internally.
Controlled TextInput
Every example on this page uses TextInput as a controlled input: its value prop always reflects a piece
of React state, and every keystroke flows back through onChangeText into a state setter, rather than reading
an uncontrolled value off a ref or relying on defaultValue. This is the same controlled-component pattern
the React reference covers for the web, applied to TextInput:
import { useState } from 'react';
import { TextInput } from 'react-native';
function NameField() {
const [name, setName] = useState('');
return (
<TextInput
value={name}
onChangeText={setName}
placeholder="Place name"
accessibilityLabel="Place name"
/>
);
}
value/onChangeText is deliberately the only pattern used in this section — defaultValue renders the
initial text once and then never updates it from state, which fights (rather than cooperates with)
`react-hook-form’s own state ownership introduced further down this page.
Keyboard Type, Return Key, and Submission
keyboardType swaps the on-screen keyboard layout for the kind of value a field expects, and returnKeyType
relabels the keyboard’s return key to match what pressing it will do; onSubmitEditing fires when that key is
pressed, most often to move focus to the next field or submit the form:
<TextInput
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
returnKeyType="next"
onSubmitEditing={() => passwordInputRef.current?.focus()}
/>
<TextInput
value={quantity}
onChangeText={setQuantity}
keyboardType="numeric"
returnKeyType="done"
/>
autoCapitalize ('none', 'sentences', 'words', 'characters'), autoCorrect, and autoComplete (a
platform-specific hint such as 'email', 'password', or 'name' that lets the OS offer or fill a saved
value) each tune the same native text-input behavior a user already expects from every other app on their
device — autoCapitalize="none" together with autoCorrect={false} is the standard combination for an email
or username field, where capitalizing or "correcting" the value would corrupt it.
Passwords and Multiline Notes
secureTextEntry masks every character as it is typed, the standard way to render a password field:
<TextInput
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
returnKeyType="done"
accessibilityLabel="Password"
/>
multiline turns the same component into a growable, multi-line text area — used below for Places's notes
field, where returnKeyType no longer applies to submission the same way (a newline is itself a valid keypress
inside a multiline field):
<TextInput
value={notes}
onChangeText={setNotes}
multiline
numberOfLines={4}
textAlignVertical="top"
placeholder="What made this place worth remembering?"
accessibilityLabel="Notes"
/>
Focus Management with Refs
Moving focus from one field to the next — e.g. from a name field to a notes field once the user presses
"next" — needs an imperative handle on the target TextInput. Under the Strict TypeScript API the typed
handle is the exported TextInputInstance type, not the component itself (see
TypeScript in React Native), so the ref is declared as
useRef<TextInputInstance> and exposes that component’s own .focus() method:
import { useRef, useState } from 'react';
import { TextInput, View } from 'react-native';
import type { TextInputInstance } from 'react-native';
function NameAndNotesFields() {
const [name, setName] = useState('');
const [notes, setNotes] = useState('');
const notesInputRef = useRef<TextInputInstance>(null);
return (
<View>
<TextInput
value={name}
onChangeText={setName}
returnKeyType="next"
onSubmitEditing={() => notesInputRef.current?.focus()}
accessibilityLabel="Place name"
/>
<TextInput
ref={notesInputRef}
value={notes}
onChangeText={setNotes}
multiline
accessibilityLabel="Notes"
/>
</View>
);
}
TextInput also exposes .blur() (to dismiss just that field’s focus/keyboard without dismissing the whole
keyboard) and .clear() (to reset its native text buffer) through the same ref, both useful alongside
.focus() for the same kind of imperative, one-off control that setting value alone cannot express.
Switch
Switch is React Native’s boolean toggle, controlled the same way TextInput is — a value prop reflecting
state, and an onValueChange callback flowing keystrokes (here, taps) back into a setter:
import { useState } from 'react';
import { Switch } from 'react-native';
function FavouriteToggle() {
const [favourite, setFavourite] = useState(false);
return <Switch value={favourite} onValueChange={setFavourite} accessibilityLabel="Favourite" />;
}
Selecting from a Closed List, Dates, and the Keyboard
Picker
@react-native-picker/picker (extracted out of React Native core into its own package some releases ago) is
the standard way to choose one value from a small, closed set of options — a native picker wheel on iOS, a
native dropdown/dialog on Android — built from a Picker wrapping one Picker.Item per option:
import { Picker } from '@react-native-picker/picker';
import { useState } from 'react';
import type { PlaceCategory } from '../features/places/types';
function CategoryPicker() {
const [category, setCategory] = useState<PlaceCategory>('restaurant');
return (
<Picker selectedValue={category} onValueChange={setCategory} accessibilityLabel="Category">
<Picker.Item label="Restaurant" value="restaurant" />
<Picker.Item label="Landmark" value="landmark" />
<Picker.Item label="Nature" value="nature" />
<Picker.Item label="Other" value="other" />
</Picker>
);
}
A Custom Modal-Based Picker
Picker renders each platform’s own native picker chrome, which is consistent with the rest of the OS but
cannot be restyled beyond a handful of props — a real constraint the moment a design calls for anything more
custom (a grid of icons, a searchable list, brand-specific styling). A Modal presenting a plain list of
pressable rows is the standard escape hatch, trading that native chrome for full design control:
// A custom category picker -- shown for comparison with Picker above.
import { useState } from 'react';
import { FlatList, Modal, Pressable, Text, View } from 'react-native';
import type { PlaceCategory } from '../features/places/types';
const CATEGORIES: PlaceCategory[] = ['restaurant', 'landmark', 'nature', 'other'];
function CategoryModalPicker({
category,
onSelect,
}: {
category: PlaceCategory;
onSelect: (category: PlaceCategory) => void;
}) {
const [visible, setVisible] = useState(false);
return (
<>
<Pressable onPress={() => setVisible(true)} accessibilityLabel="Category" accessibilityRole="button">
<Text>{category}</Text>
</Pressable>
<Modal visible={visible} transparent animationType="slide" onRequestClose={() => setVisible(false)}>
<View style={{ marginTop: 'auto', backgroundColor: '#fff' }}>
<FlatList
data={CATEGORIES}
keyExtractor={(item) => item}
renderItem={({ item }) => (
<Pressable
onPress={() => {
onSelect(item);
setVisible(false);
}}
>
<Text>{item}</Text>
</Pressable>
)}
/>
</View>
</Modal>
</>
);
}
Modal and this pattern are covered in full on
Modals, Alerts, and Progress (later in this section);
this page’s version is deliberately minimal, to compare directly against Picker above.
Date and Time: DateTimePicker
@react-native-community/datetimepicker’s `DateTimePicker selects a date or time, but its presentation
differs by platform in a way no other control on this page does: on Android, setting it up always opens a
native dialog on top of the screen (there is no true "inline" rendering), and it closes itself as soon as a
value is chosen or cancelled; on iOS, display="default" shows a compact inline field that expands into a
calendar/wheel on tap, while display="spinner" renders the wheel inline at all times. Both platforms return
their result through the same onChange callback, but only Android calls it once and dismisses; iOS’s inline
and spinner modes keep firing onChange as the user scrubs the wheel:
import DateTimePicker from '@react-native-community/datetimepicker';
import { useState } from 'react';
import { Platform } from 'react-native';
function VisitedDatePicker({ value, onChange }: { value: Date; onChange: (date: Date) => void }) {
const [showPicker, setShowPicker] = useState(Platform.OS === 'ios');
return (
<>
{Platform.OS === 'android' && (
<Pressable onPress={() => setShowPicker(true)}>
<Text>{value.toLocaleDateString()}</Text>
</Pressable>
)}
{showPicker && (
<DateTimePicker
value={value}
mode="date"
display={Platform.OS === 'ios' ? 'default' : 'default'}
onChange={(_event, selectedDate) => {
setShowPicker(Platform.OS === 'ios');
if (selectedDate) {
onChange(selectedDate);
}
}}
/>
)}
</>
);
}
KeyboardAvoidingView
KeyboardAvoidingView resizes or repositions its children when the on-screen keyboard opens, so the field
currently focused stays visible above it instead of being covered. Its behavior prop is itself
platform-specific, because iOS and Android resize the view differently once the keyboard opens:
import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
function FormScreen({ children }: { children: React.ReactNode }) {
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 0}
>
<ScrollView contentContainerStyle={{ padding: 16 }}>{children}</ScrollView>
</KeyboardAvoidingView>
);
}
"padding" (the common iOS choice) grows the view’s bottom padding by the keyboard’s height, pushing content
up without resizing it; "height" (the common Android choice) instead shrinks the view’s own height by that
same amount — Android often needs neither at all when android:windowSoftInputMode is already configured to
resize the window itself, in which case behavior={undefined} (the default) is correct and KeyboardAvoidingView
becomes a no-op wrapper.
Keyboard Events and Keyboard.dismiss()
The Keyboard module exposes the keyboard’s own show/hide lifecycle as events, and an imperative
Keyboard.dismiss() for closing it from code (e.g. a "Done" toolbar button, or tapping outside every field):
import { useEffect, useState } from 'react';
import { Keyboard } from 'react-native';
function useKeyboardVisible(): boolean {
const [visible, setVisible] = useState(false);
useEffect(() => {
const showSubscription = Keyboard.addListener('keyboardDidShow', () => setVisible(true));
const hideSubscription = Keyboard.addListener('keyboardDidHide', () => setVisible(false));
return () => {
showSubscription.remove();
hideSubscription.remove();
};
}, []);
return visible;
}
keyboardWillShow/keyboardWillHide (iOS-only, firing before the animation starts rather than after it
finishes) are the pair to reach for when a UI element needs to animate in lockstep with the keyboard itself,
rather than snapping into place once it has already finished moving.
InputAccessoryView (iOS)
InputAccessoryView pins a custom toolbar directly above the keyboard — iOS-only, with no Android
equivalent — most commonly a "Done" button that calls Keyboard.dismiss(), associated with one or more
TextInput`s by a shared `inputAccessoryViewID:
import { InputAccessoryView, Keyboard, Platform, Pressable, Text, TextInput } from 'react-native';
const accessoryViewID = 'notes-field-accessory';
function NotesFieldWithDoneBar({ value, onChangeText }: { value: string; onChangeText: (text: string) => void }) {
return (
<>
<TextInput
value={value}
onChangeText={onChangeText}
multiline
inputAccessoryViewID={Platform.OS === 'ios' ? accessoryViewID : undefined}
accessibilityLabel="Notes"
/>
{Platform.OS === 'ios' && (
<InputAccessoryView nativeID={accessoryViewID}>
<Pressable onPress={() => Keyboard.dismiss()} style={{ alignSelf: 'flex-end', padding: 8 }}>
<Text>Done</Text>
</Pressable>
</InputAccessoryView>
)}
</>
);
}
Form State and Validation: React Hook Form and Zod
A handful of useState calls, one per field, is manageable for two or three inputs but stops scaling once a
form needs per-field validation, submit-time error messages, and a single source of truth for "is this form
valid" — exactly the problem React Hook Form solves. useForm owns every field’s value and validation
state internally (rather than through a re-render-triggering useState per field), Controller bridges that
internal state to a controlled component like TextInput, Picker, Switch, or DateTimePicker that React
Hook Form does not already know how to drive directly, and handleSubmit wraps the actual submit handler so it
only runs once every field has passed validation. Zod supplies that validation as a schema rather than
hand-written if checks, and @hookform/resolvers/zod’s `zodResolver is the adapter connecting a Zod schema
to useForm’s own `resolver option.
Places: AddPlaceScreen
Places's "add place" form brings every piece above together: a controlled name field, a multiline notes
field, a category Picker over the four PlaceCategory values, a DateTimePicker for the visited date, a
favourite Switch, React Hook Form plus Zod for state and validation, and a useAddPlace() mutation
(TanStack Query’s useMutation, covered in full on
Networking and Data Fetching) for submission.
// src/features/places/hooks/useAddPlace.ts (excerpt)
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { PlacesApi } from '../api/PlacesApi';
import type { NewPlaceInput, Place } from '../types';
export function useAddPlace() {
const queryClient = useQueryClient();
return useMutation<Place, Error, NewPlaceInput>({
mutationFn: (input) => PlacesApi.addPlace(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['places'] });
},
});
}
// src/features/places/screens/AddPlaceScreen.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import DateTimePicker from '@react-native-community/datetimepicker';
import { Picker } from '@react-native-picker/picker';
import { useRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import {
KeyboardAvoidingView,
Platform,
ScrollView,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import type { TextInputInstance } from 'react-native';
import { z } from 'zod';
import { useAddPlace } from '../hooks/useAddPlace';
import type { NewPlaceInput, PlaceCategory } from '../types';
import { useTheme } from '../../../theme/ThemeProvider';
const PLACE_CATEGORIES: PlaceCategory[] = ['restaurant', 'landmark', 'nature', 'other'];
const addPlaceSchema = z.object({
name: z.string().min(1, 'Name is required').max(120, 'Name is too long'),
notes: z.string().max(2000, 'Notes are too long'),
category: z.enum(['restaurant', 'landmark', 'nature', 'other']),
visitedAt: z.string().min(1, 'Visited date is required'),
favourite: z.boolean(),
});
type AddPlaceFormValues = z.infer<typeof addPlaceSchema>;
export function AddPlaceScreen({ onSubmitted }: { onSubmitted: () => void }) {
const theme = useTheme();
const notesInputRef = useRef<TextInputInstance>(null);
const addPlace = useAddPlace();
const { control, handleSubmit, formState } = useForm<AddPlaceFormValues>({
resolver: zodResolver(addPlaceSchema),
defaultValues: {
name: '',
notes: '',
category: 'restaurant',
visitedAt: new Date().toISOString(),
favourite: false,
},
});
const onSubmit = (values: AddPlaceFormValues) => {
const input: NewPlaceInput = {
name: values.name,
notes: values.notes,
category: values.category,
visitedAt: values.visitedAt,
latitude: null,
longitude: null,
photoUri: null,
};
addPlace.mutate(input, { onSuccess: onSubmitted });
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={{ padding: theme.spacing.lg }}>
<Controller
control={control}
name="name"
render={({ field, fieldState }) => (
<View>
<TextInput
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
returnKeyType="next"
onSubmitEditing={() => notesInputRef.current?.focus()}
accessibilityLabel="Place name"
/>
{fieldState.error && <Text style={{ color: 'red' }}>{fieldState.error.message}</Text>}
</View>
)}
/>
<Controller
control={control}
name="notes"
render={({ field }) => (
<TextInput
ref={notesInputRef}
value={field.value}
onChangeText={field.onChange}
onBlur={field.onBlur}
multiline
numberOfLines={4}
accessibilityLabel="Notes"
/>
)}
/>
<Controller
control={control}
name="category"
render={({ field }) => (
<Picker selectedValue={field.value} onValueChange={field.onChange} accessibilityLabel="Category">
{PLACE_CATEGORIES.map((category) => (
<Picker.Item key={category} label={category} value={category} />
))}
</Picker>
)}
/>
<Controller
control={control}
name="visitedAt"
render={({ field }) => (
<DateTimePicker
value={new Date(field.value)}
mode="date"
onChange={(_event, selectedDate) => {
if (selectedDate) {
field.onChange(selectedDate.toISOString());
}
}}
/>
)}
/>
<Controller
control={control}
name="favourite"
render={({ field }) => (
<Switch value={field.value} onValueChange={field.onChange} accessibilityLabel="Favourite" />
)}
/>
<Text onPress={handleSubmit(onSubmit)} accessibilityRole="button">
{formState.isSubmitting || addPlace.isPending ? 'Saving...' : 'Save place'}
</Text>
</ScrollView>
</KeyboardAvoidingView>
);
}
Each Controller closes the gap between React Hook Form’s own internal field state and a controlled component
that expects a value prop plus a change callback: field.value/field.onChange play the exact same role
value/onChangeText (or value/onValueChange for Switch) played throughout this page, just routed
through React Hook Form instead of a local useState. handleSubmit(onSubmit) runs Zod’s validation first — via zodResolver — and only calls onSubmit (which builds the canonical NewPlaceInput and calls
addPlace.mutate) once every field passes; a failing field instead populates fieldState.error for that
Controller, shown here only for the name field for brevity. accessibilityLabel on every input stands in for
a visible <label>, and the full accessibility treatment — announced errors, focus order, accessible
grouping — is Accessibility's own subject.