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.

A Controlled Input’s Value Flow

stateDiagram-v2 [*] --> Empty Empty --> Typing: user types (onChangeText fires) Typing --> StateUpdated: setState(newValue) StateUpdated --> Rerendered: value prop reflects new state Rerendered --> Typing: user types again Rerendered --> Validating: blur or submit Validating --> Invalid: schema check fails Validating --> Valid: schema check passes Invalid --> Typing: user edits again Valid --> [*]