EduFlow

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.

  1. 1A draft of raw strings
  2. 2Moving between fields
  3. 3Keeping fields above the keyboard
  4. 4A word about dates
  5. 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:

src/app/new-hike.tsx
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:

TSX
<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:

TSX
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.

src/lib/validateHike.ts
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.

TSX
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 KeyboardAvoidingView with behavior="padding" on iOS, and give the inner ScrollView keyboardShouldPersistTaps="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.

Further reading

Check your understanding

4 questions

Question 1 of 4

Users report they have to tap Save twice on Trailhead’s form while the keyboard is open. The first tap only closes the keyboard. What fixes it?

Question 2 of 4

On iOS, the notes field at the bottom of the form is hidden behind the keyboard when focused. Which setup is the usual fix?

Question 3 of 4

When should Trailhead show the error “Distance must be a number” under the distance field?

Question 4 of 4

How do you make the return key on the name field move focus to the distance field?

Practice

Code playground

Validate the add-hike form

Write validateHikeForm(draft, today) for Trailhead’s form. draft holds the raw strings the user typed: name, date, distance, duration, notes. today is today’s date as YYYY-MM-DD. Return an object with a message for each invalid field (any non-empty string); return {} when everything is valid.

  • name: required after trimming; at most 60 characters.
  • date: YYYY-MM-DD, a date that exists (2026-02-30 doesn’t), and not after today.
  • distance: a number above 0 and at most 200; a comma decimal like 8,4 is fine.
  • duration: optional; if filled in, whole minutes from 1 to 1440.
  • notes: optional; at most 500 characters.
// Return an object with one message per invalid field. {} means the draft is valid.
// `today` is the current date as 'YYYY-MM-DD', passed in so the function stays pure.
function validateHikeForm(draft, today) {
  const errors = {};
  if (draft.name === '') errors.name = 'Give the hike a name.';
  return errors;
}

const draft = { name: 'Ridge Loop', date: '2026-10-04', distance: '8,4', duration: '', notes: '' };
console.log(validateHikeForm(draft, '2026-10-05'));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • A complete, sensible draft has no errors
  • Blank and overly long names are rejected
  • Badly formatted, impossible and future dates are rejected
  • Missing, non-numeric and zero distances are rejected
  • A filled-in duration must be whole minutes from 1 to 1440
  • Several invalid fields are all reported at once