Chapters

Hide chapters

React Apprentice

First Edition · web · React 8.0.0 · Visual Studio Code

Section I: Rendering Right

Section 1: 7 chapters
Show chapters Hide chapters

9. Forms & User Input
Written by Eli Ganim

In the previous chapter, clicks started changing your UI: Each card runs the event-to-state-to-render loop with its own favorite toggle. Clicks are the simplest input, though — one bit of information per interaction.

This chapter handles the richer kind: typing. You’ll build the Learning Tracker’s live search, then a complete “add a personal course” form with text fields, selects, a checkbox, validation and accessible feedback. Along the way you’ll learn React’s approach to form elements — the controlled input — and hold state that’s an object rather than a single value.

By the end, the catalog stops being read-only: Users can find courses as fast as they can type, and add their own courses to the list.

Building a Controlled Search

Search first, because it’s the controlled-input pattern at its smallest. Create src/components/SearchBar.tsx:

type SearchBarProps = {
  query: string
  onQueryChange: (query: string) => void
}

function SearchBar({ query, onQueryChange }: SearchBarProps) {
  return (
    <div className="search-bar">
      <label htmlFor="course-search">Search courses</label>
      <input
        id="course-search"
        type="search"
        value={query}
        placeholder="Try css or types"
        onChange={(event) => onQueryChange(event.target.value)}
      />
    </div>
  )
}

export default SearchBar

Look at the input’s two wired props, because they define the pattern. value={query} means the box displays the query prop — always, only, exactly. onChange fires whenever the value changes — keystrokes, pastes, autofill — and its handler reports the box’s would-be new text, event.target.value, to whoever owns the state.

That’s a controlled input: React state is the single source of truth, and the DOM element just displays it. Notice the component owns no state at all — it receives the value and a callback, like Button receives a label and onClick. Also note htmlFor connecting the label to the input by id: the rename you met in Chapter 3, finally in action.

The controlled loop: typing updates state, state repaints the box — and the same render derives the filtered list.
The controlled loop: typing updates state, state repaints the box — and the same render derives the filtered list.

Now give the search its state owner: App, which holds no state yet. In src/App.tsx, add the import at the top:

import { useState } from 'react'

Same hook, new home — App is about to own state for the first time. Declare the query state at the top of the App function:

const [query, setQuery] = useState('')

An empty string to start — a search box with nothing typed. Import the component below PageHeader:

import SearchBar from './components/SearchBar.tsx'

The usual default-export import. Render it in the JSX, between the featured banner and CourseList:

<SearchBar query={query} onQueryChange={setQuery} />

One elegant detail: onQueryChange={setQuery} passes the state setter itself as the callback. It already has exactly the right shape — a function taking the new string — so no wrapper is needed.

Save and check the browser:

The controlled search box, rendered and wired — but not yet read by anything.
The controlled search box, rendered and wired — but not yet read by anything.

A labeled search box appears, and typing in it works. Nothing filters yet — the state updates, but nothing reads it. Fix that next.

Deriving the Search Results

Chapter 5 taught the move: Don’t store filtered results, derive them. In App, below the level-filter block, add:

const trimmedQuery = query.trim().toLowerCase()
if (trimmedQuery !== '') {
  visibleCourses = visibleCourses.filter((course) =>
    course.title.toLowerCase().includes(trimmedQuery),
  )
}

Three string methods do the matching. trim cuts surrounding whitespace so “ css “ still works, toLowerCase on both sides makes the match case-insensitive, and includes answers whether one string contains another. The filter stacks on top of the level filter — each narrows visibleCourses further.

Save, then type css into the search box:

Live search: every keystroke re-renders, re-derives and re-filters.
Live search: every keystroke re-renders, re-derives and re-filters.

One card. Delete a character, add a character — the list answers every edit, because each one runs the whole loop from the diagram: state update, re-render, re-derive.

Now type zzz. The empty state appears, but it tells a small lie: “No courses match this level yet.” The level is fine — it’s the search that came up empty. Give App a smarter message. Add this below the featuredCourse block:

let emptyMessage = 'No courses match this level yet.'
if (courses.length === 0) {
  emptyMessage = 'The catalog is empty — add a course below.'
} else if (trimmedQuery !== '') {
  emptyMessage = 'No courses match "' + query.trim() + '".'
}

Three different truths get three different sentences: a catalog with nothing in it at all, a search that matched nothing, and a level with no courses. The message is derived state — computed fresh on every render, never stored. CourseList needs to accept it. In src/components/CourseList.tsx, extend the props:

type CourseListProps = {
  courses: Course[]
  emptyMessage: string
}

Update the destructuring to { courses, emptyMessage }, and change the EmptyState line to use it:

<EmptyState message={emptyMessage} />

The list no longer invents its own wording — it displays whatever its owner decided. Finally, pass the message from App’s JSX:

<CourseList
  courses={visibleCourses}
  emptyMessage={emptyMessage}
/>

Two props now travel down: the data and the words for its absence. Save and search for zzz again:

An honest empty state: it names the query that failed.
An honest empty state: it names the query that failed.

Note: React also supports uncontrolled inputs, where the DOM keeps the value and you read it out when needed. Controlled inputs earn their extra wiring: State can validate, transform, derive and reset the value at any moment, which is exactly what forms need. This book uses controlled inputs throughout; file “uncontrolled” away as a term you’ll meet in other codebases.

Preparing the Catalog to Grow

The second half of this chapter adds courses: Data that changes while the app runs — and that the UI must react to — is state’s job, not an import’s. In src/data/courses.ts, rename the export to say what it now is:

export const initialCourses: Course[] = [

Only the name changes — the array is now explicitly a starting point rather than the catalog itself. In src/App.tsx, update the import to the new name:

import { initialCourses } from './data/courses.ts'

Same array, honest new name. Now turn the catalog into state, above the query line:

const [courses, setCourses] = useState(initialCourses)

The state variable keeps the old courses name, so every derivation below — filters, featured, the list — compiles untouched. Save and check the browser: no visible change, but the catalog is now data the app can change while running.

Building the Add Course Form

Time for the main event: a real form. Create src/components/AddCourseForm.tsx and build it top to bottom, starting with the imports and types:

import { useState } from 'react'
import type { ChangeEvent, FormEvent } from 'react'
import type {
  Course,
  CourseCategory,
  CourseLevel,
} from '../types/course.ts'

type CourseFormState = {
  title: string
  description: string
  category: CourseCategory
  level: CourseLevel
  isFavorite: boolean
}

type FormErrors = {
  title?: string
  description?: string
}

CourseFormState is the shape of the form itself — one property per field, all of them always present. FormErrors holds validation messages, each optional because a field might be fine. The two event types from React will annotate handlers in a moment.

Continue with three constants below the types:

const emptyForm: CourseFormState = {
  title: '',
  description: '',
  category: 'Frontend',
  level: 'Beginner',
  isFavorite: false,
}

const categories: CourseCategory[] = [
  'Frontend',
  'Languages',
  'Design',
]

const levels: CourseLevel[] = [
  'Beginner',
  'Intermediate',
  'Advanced',
]

emptyForm is the form’s blank slate — its initial state, and later its reset target. The two arrays list every legal option for the selects; typing them as CourseCategory[] and CourseLevel[] means a typo here is a compile error, not a mystery bug.

Now the component itself, with its state and first handler:

type AddCourseFormProps = {
  onAdd: (course: Course) => void
}

function AddCourseForm({ onAdd }: AddCourseFormProps) {
  const [form, setForm] = useState(emptyForm)
  const [errors, setErrors] = useState<FormErrors>({})
  const [successMessage, setSuccessMessage] = useState('')

  function handleTitleChange(
    event: ChangeEvent<HTMLInputElement>,
  ) {
    setForm({ ...form, title: event.target.value })
  }
}

Three pieces of state: the form values as one object, the errors, and a success message. The errors state shows the one case where inference needs help — useState({}) alone would infer an empty object type, so useState<FormErrors>({}) names the intended shape.

The handler introduces this chapter’s most important line: { ...form, title: event.target.value }. This handler is extracted rather than inline, so it needs its parameter typed — ChangeEvent<HTMLInputElement> is React’s type for “a change happened on an input element.”

Note: The three dots are object spread: { ...form, title: newValue } builds a new object by copying every property of form, then overriding title. The original object is untouched. React state must be replaced rather than edited — Chapter 10 digs into exactly why — and spread is the tool that makes replacing painless.

Next, the two select handlers, below handleTitleChange:

function handleCategoryChange(
  event: ChangeEvent<HTMLSelectElement>,
) {
  const value = categories.find(
    (category) => category === event.target.value,
  )
  if (value !== undefined) {
    setForm({ ...form, category: value })
  }
}

function handleLevelChange(
  event: ChangeEvent<HTMLSelectElement>,
) {
  const value = levels.find(
    (level) => level === event.target.value,
  )
  if (value !== undefined) {
    setForm({ ...form, level: value })
  }
}

These solve a quiet type problem. The DOM hands you event.target.value as a plain string, but the form state demands a CourseCategory — and TypeScript rightly refuses to assign one to the other. Searching the typed array with find narrows the string to a real member of the union, the same guard-then-use move you learned with the featured course.

Now the submit handler, below the others:

function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault()

  const nextErrors: FormErrors = {}
  if (form.title.trim() === '') {
    nextErrors.title = 'Give the course a title.'
  }
  if (form.description.trim() === '') {
    nextErrors.description =
      'Describe the course in a sentence.'
  }
  setErrors(nextErrors)

  const hasErrors =
    nextErrors.title !== undefined ||
    nextErrors.description !== undefined
  if (hasErrors) {
    setSuccessMessage('')
    return
  }

  const newCourse: Course = {
    id: crypto.randomUUID(),
    title: form.title.trim(),
    description: form.description.trim(),
    category: form.category,
    level: form.level,
    isFavorite: form.isFavorite,
  }
  onAdd(newCourse)
  setForm(emptyForm)
  setSuccessMessage(
    'Added "' + newCourse.title + '" to the catalog.',
  )
}

Walk it in four beats. First, event.preventDefault() — a form’s default submit navigates the browser to a new page, exactly what a React app doesn’t want, so the handler cancels it and takes over.

Second, validation builds a fresh nextErrors object and stores it; the checks read the local object, not the errors state, because state is a snapshot that won’t reflect setErrors until next render. Third, on failure the handler clears any stale success message and bails out early.

Fourth, on success it assembles a complete Course — the Course contract makes forgetting a field a compile error, and crypto.randomUUID() asks the browser to mint a unique id — hands it up through onAdd, resets the form to emptyForm and announces the win.

All that’s left is the UI. Add the return at the bottom of the component, starting with the title field:

return (
  <form className="add-course-form" onSubmit={handleSubmit}>
    <h2>Add a Personal Course</h2>
    <div className="field">
      <label htmlFor="course-title">Title</label>
      <input
        id="course-title"
        type="text"
        value={form.title}
        onChange={handleTitleChange}
        aria-invalid={errors.title !== undefined}
        aria-describedby={
          errors.title === undefined
            ? undefined
            : 'course-title-error'
        }
      />
      {errors.title !== undefined && (
        <p id="course-title-error" className="field-error">
          {errors.title}
        </p>
      )}
    </div>
  </form>
)

The submit handler hangs on the form element’s onSubmit — not on a button — so pressing Enter in the title input submits too (the textarea keeps Enter for newlines). The error wiring is the accessible-forms pattern worth memorizing: aria-invalid flags the field, the error paragraph carries an id and aria-describedby points at it only while the error exists, so a screen reader reads the message when the user reaches the field. Production forms often add one more nicety — moving focus to the first failing field on submit — which you can file away as the upgrade path.

Add the description field below the title’s closing </div>:

<div className="field">
  <label htmlFor="course-description">Description</label>
  <textarea
    id="course-description"
    rows={3}
    value={form.description}
    onChange={(event) =>
      setForm({ ...form, description: event.target.value })
    }
    aria-invalid={errors.description !== undefined}
    aria-describedby={
      errors.description === undefined
        ? undefined
        : 'course-description-error'
    }
  />
  {errors.description !== undefined && (
    <p
      id="course-description-error"
      className="field-error"
    >
      {errors.description}
    </p>
  )}
</div>

Same pattern, two differences: a textarea for multi-line text — controlled through value just like an input, unlike plain HTML — and an inline arrow handler, since it’s a one-liner and TypeScript infers the event type for inline JSX handlers all by itself.

Now the two selects, below the description field:

<div className="field">
  <label htmlFor="course-category">Category</label>
  <select
    id="course-category"
    value={form.category}
    onChange={handleCategoryChange}
  >
    {categories.map((category) => (
      <option key={category} value={category}>
        {category}
      </option>
    ))}
  </select>
</div>
<div className="field">
  <label htmlFor="course-level">Level</label>
  <select
    id="course-level"
    value={form.level}
    onChange={handleLevelChange}
  >
    {levels.map((level) => (
      <option key={level} value={level}>
        {level}
      </option>
    ))}
  </select>
</div>

The options render with map from the typed arrays — the Chapter 5 list skills, now generating form controls — and each option’s value doubles as its key. A controlled select takes value on the select itself, not selected on options as raw HTML does.

Last stretch — checkbox, submit button and success message, below the level field:

<label className="checkbox-label">
  <input
    type="checkbox"
    checked={form.isFavorite}
    onChange={(event) =>
      setForm({ ...form, isFavorite: event.target.checked })
    }
  />
  Add to my favorites right away
</label>
<button type="submit" className="button primary">
  Add course
</button>
{successMessage !== '' && (
  <p className="success-note" role="status">
    {successMessage}
  </p>
)}

Three last teachings hide here. A checkbox is controlled through checked and read through event.target.checked — booleans, not strings — and wrapping the input in its label associates them without ids.

The submit button is a plain element rather than your Button component, for a reason worth knowing: Button pins type="button" precisely so it never submits forms, and this button’s whole job is submitting. It borrows the same CSS classes, so it looks identical.

And the success paragraph carries role="status" — a polite live region, so screen readers announce the added course without the user hunting for it.

Close the component with export default AddCourseForm at the bottom of the file. Save — and expect no change in the browser yet, because nothing renders the form until it’s wired into App.

Wiring the Form to the Catalog

The form emits a course through onAdd; App catches it. In src/App.tsx, import the form below CourseList:

import AddCourseForm from './components/AddCourseForm.tsx'

The last new component of the chapter. Add its handler inside App, below the featuredCourse block:

function handleAddCourse(newCourse: Course) {
  setCourses([...courses, newCourse])
}

Array spread is object spread’s sibling: [...courses, newCourse] builds a new array with every existing course plus the new one at the end — again replacing, never editing. The Course type needs importing too; extend the type import line:

import type { Course, CourseLevel } from './types/course.ts'

One more name in the braces — type imports grow the same way value imports do. Render the form at the bottom of the JSX, below CourseList:

<AddCourseForm onAdd={handleAddCourse} />

Data down, actions up: The form receives one callback and stays ignorant of what the catalog does with new courses. Then add the form’s styles to the bottom of src/App.css, in two chunks. First, the search bar and the shared look of every control:

.search-bar {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 20px 0;
}

.search-bar label {
  font-weight: 600;
}

.search-bar input {
  flex: 1;
  max-width: 380px;
}

input,
textarea,
select {
  font: inherit;
  color: inherit;
  background: #ffffff;
  border: 1px solid #b7c0cc;
  border-radius: 8px;
  padding: 8px 10px;
}

input:focus-visible,
textarea:focus-visible,
select:focus-visible {
  outline: 3px solid #2f6fde;
  outline-offset: 2px;
}

font: inherit makes form controls stop using their tiny browser defaults and match the app, and every control gets the same focus treatment as buttons — Chapter 6’s keyboard promise extended to typing. Now the form panel itself, below that:

.add-course-form {
  margin-top: 32px;
  background: #ffffff;
  border: 1px solid #d8dee6;
  border-radius: 12px;
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 14px;
}

.add-course-form h2 {
  margin: 0;
  font-size: 1.2rem;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 6px;
  width: 100%;
  max-width: 420px;
}

.field label {
  font-weight: 600;
}

.field-error {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 600;
  color: #a4262c;
}

.checkbox-label {
  display: flex;
  align-items: center;
  gap: 8px;
}

.success-note {
  margin: 0;
  font-weight: 600;
  color: #0f5426;
}

The panel borrows the card look, fields stack their label, control and error vertically, and the feedback colors are dark enough to pass contrast — with the words doing the real work, never color alone. Save and scroll to the bottom of the page:

The form, blank and waiting — every control controlled.
The form, blank and waiting — every control controlled.

Testing the Form’s Three Outcomes

Exercise it like a user would. First, click Add course with everything empty:

Validation: each failing field flagged and described for assistive technology.
Validation: each failing field flagged and described for assistive technology.

Both required fields show their errors, and nothing was added. Now fill in a title and description — invent a course you wish existed — pick a category, tick the favorite checkbox and submit:

Success: the form resets, the status announces and the catalog grows by one card.
Success: the form resets, the status announces and the catalog grows by one card.

The form clears back to emptyForm, the green status line names your course and — scroll up — the catalog has a new card, favorite button already pressed thanks to the checkbox. Search for your new course’s title: found, because the new card flows through the same derived pipeline as everything else.

Challenge: Validate a Duration

Personal courses are self-paced today. Add an optional Duration in hours text field between description and category: Left empty, the course stays self-paced; filled, it must be a positive number, or the form refuses with a field error.

A few hints:

  • Add duration: string to the form state — text fields hold strings, even numeric ones. Convert with Number(...) at validation time, and check the result with Number.isFinite, which rejects failed conversions and infinities in one move.
  • The optional field is only invalid when non-empty and broken — check the trimmed value.
  • On success, add durationHours to the new course only when a duration was given; the optional property in Course makes that legal.

You’ll find a complete solution in the challenge folder of this chapter’s materials.

Key Points

  • A controlled input displays state through value and reports every value change through onChange — React state is the single source of truth.
  • Derive search results, counts and messages from state on every render; store only the query itself.
  • Extracted handlers type their events (ChangeEvent<HTMLInputElement>, FormEvent<HTMLFormElement>); inline arrow handlers infer them.
  • Object spread — { ...form, field: value } — replaces state with an updated copy instead of editing it; array spread grows lists the same way.
  • Call event.preventDefault() in onSubmit to stop the browser’s page-navigating default, and validate against your local nextErrors, not just-set state.
  • Wire errors accessibly: aria-invalid on the field, an error paragraph with an id and aria-describedby pointing at it while it exists.
  • DOM values are strings — narrow them into unions with a typed array and find, not a type assertion.
  • Checkboxes are controlled via checked; selects via value on the select; textareas via value like inputs.
  • Reset a form by setting state back to a saved empty shape, and announce success with a role="status" live region.

Where to Go From Here?

The Learning Tracker now listens as fast as you can type and grows as fast as you can submit. You’ve also been following a rule on faith all chapter: always spread, never assign. Why exactly does form.title = value break React while { ...form, title: value } works?

Chapter 10 answers that properly — snapshots, batching, immutability and the difference between stored and derived state. It’s the chapter that turns “I can make state work” into “I know why it works,” and it hunts down a few bugs in the Tracker while it’s at it.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.