Section 3 · Navigation and State
Forms and the Keyboard
20 min3 min readCode playground
Lesson at a glance
Build Trailhead’s add-hike form so it works with a phone keyboard, moving between fields, staying visible above the keyboard, saving on the first tap, and validating with clear per-field errors.
- 1A draft of raw strings
- 2Moving between fields
- 3Keeping fields above the keyboard
- 4A word about dates
- 5Validation that doesn’t nag
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Forms are where mobile apps feel most different from the web. The keyboard covers half the screen, there’s no Tab key, the first tap on a button sometimes only closes the keyboard, and typing on glass is slow enough that every unnecessary error message feels personal. Trailhead’s “Log a hike” form has five fields: name, date, distance, duration and notes. Here’s how to make it pleasant.
A draft of raw strings
Keep everything the user typed in one object of strings, exactly as typed. Parse when you need numbers, as you did with parseDistanceKm in section 1:
import { useRef, useState, type ComponentRef } from 'react';
import { TextInput } from 'react-native';
type Draft = { name: string; date: string; distance: string; duration: string; notes: string };
// Today's date in the phone's time zone. toISOString() would give the UTC date,
// which is already tomorrow (or still yesterday) for part of the day in most places.
function localToday() {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
const emptyDraft = (): Draft => ({ name: '', date: localToday(), distance: '', duration: '', notes: '' });
export default function NewHike() {
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [submitted, setSubmitted] = useState(false);
const [touched, setTouched] = useState<Partial<Record<keyof Draft, boolean>>>({});
const distanceRef = useRef<ComponentRef<typeof TextInput>>(null);
const durationRef = useRef<ComponentRef<typeof TextInput>>(null);
const set = (field: keyof Draft) => (text: string) => setDraft((d) => ({ ...d, [field]: text }));
// …errors, save and JSX below
}The set('distance') helper gives each input an onChangeText without five near-identical functions. Storing strings means “8,” stays “8,” while the user types, instead of collapsing to 8 or NaN.
Moving between fields
There’s no Tab key, so the return key does the job. Label it with returnKeyType="next", and when it’s pressed, focus the next input through a ref:
<TextInput
value={draft.name}
onChangeText={set('name')}
placeholder="Ridge Loop"
autoCapitalize="words"
returnKeyType="next"
submitBehavior="submit"
onSubmitEditing={() => distanceRef.current?.focus()}
onBlur={() => setTouched((t) => ({ ...t, name: true }))}
/>
<TextInput
ref={distanceRef}
value={draft.distance}
onChangeText={set('distance')}
keyboardType="decimal-pad"
returnKeyType="next"
onSubmitEditing={() => durationRef.current?.focus()}
onBlur={() => setTouched((t) => ({ ...t, distance: true }))}
/>submitBehavior="submit" keeps the keyboard open while focus moves, so it doesn’t flicker closed and open again. On the last field, use returnKeyType="done" and let it submit the form. One honest caveat: numeric keypads on iOS have no return key at all, so users tap the next field or a toolbar button instead. That’s normal, and another reason not to rely on the keyboard alone.
Keeping fields above the keyboard
When the keyboard opens, it sits on top of your app. A field near the bottom (the notes) disappears behind it. The standard fix wraps the form in KeyboardAvoidingView and puts a ScrollView inside:
import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView
contentContainerStyle={{ padding: 16, gap: 16 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{/* fields and the Save button */}
</ScrollView>
</KeyboardAvoidingView>On iOS, padding adds bottom padding equal to the keyboard height, so the ScrollView shrinks and the focused field can scroll into view. On Android the window usually resizes for the keyboard already, so undefined is the recommended value. keyboardDismissMode="on-drag" closes the keyboard when the user scrolls, which feels natural.
For long forms with many fields, Expo’s docs recommend react-native-keyboard-controller, whose KeyboardAwareScrollView scrolls the focused field into view automatically; the same library’s KeyboardToolbar adds previous, next and done buttons above the keyboard. It needs a development build, which Trailhead gets in section 4.
A word about dates
Trailhead’s draft keeps the date as a YYYY-MM-DD string, defaulted to today, because most hikes are logged the same day. Typing dates on a phone keyboard is miserable, though, so production apps show the platform’s own date picker: a wheel or calendar on iOS, a dialog on Android. The community package @react-native-community/datetimepicker wraps both, and it’s a good example of a component whose behaviour differs enough by platform that you’ll want the per-platform files from section 2.
Validation that doesn’t nag
Keep the rules in a pure function: draft in, errors out. An empty object means valid.
export function validateHike(d: Draft) {
const errors: Partial<Record<keyof Draft, string>> = {};
if (!d.name.trim()) errors.name = 'Give the hike a name.';
if (parseDistanceKm(d.distance) === null) errors.distance = 'Enter a distance in km, like 8.4.';
return errors;
}Then decide when to show each message. Showing errors on the first keystroke punishes people for not having finished typing. Waiting for an alert after Save hides the form behind a list they must memorize. The pattern that works: show a field’s error once the user has left it (touched) or pressed Save (submitted), next to the field, and keep it live from then on so it disappears the moment they fix it.
const errors = validateHike(draft);
const show = (field: keyof Draft) => (submitted || touched[field]) && errors[field];
function save() {
setSubmitted(true);
if (Object.keys(errors).length > 0) return;
dispatch({ type: 'added', hike: toHike(draft) });
router.back();
}
// under the distance input:
{show('distance') ? <Text style={{ color: '#b42318' }}>{errors.distance}</Text> : null}Two accessibility touches cost a line each: give every input an accessibilityLabel that matches its visible label, and keep error text right below its field so screen readers reach it next.
toHike converts the valid draft into a real Hike (parsing numbers, generating an id). After saving, router.back() returns to the log, where the new hike is already at the top because the reducer put it there.
The form currently opens as a normal pushed screen. Next you’ll present it as a modal, protect screens behind sign-in, and let links open Trailhead on a specific hike.
Key takeaways
- Keep the form draft as raw strings in one state object, and parse and validate only when you need the result.
- Chain fields with
returnKeyType="next"and refs, so the return key moves to the next input instead of closing the keyboard. - Wrap the form in
KeyboardAvoidingViewwithbehavior="padding"on iOS, and give the inner ScrollViewkeyboardShouldPersistTaps="handled". - Validate in a pure function that returns an errors object, and show each error next to its field after the first submit or blur.