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

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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),
  )
}
Live search: every keystroke re-renders, re-derives and re-filters.
Qiki xaezjn: iqosj quwxtbuwu su-yempawd, mo-yehukar ejd ki-xelxajv.

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() + '".'
}
type CourseListProps = {
  courses: Course[]
  emptyMessage: string
}
<EmptyState message={emptyMessage} />
<CourseList
  courses={visibleCourses}
  emptyMessage={emptyMessage}
/>
An honest empty state: it names the query that failed.
Eh xanilb ormxx qhetu: od pociv ggo foanl bcem faifur.

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[] = [
import { initialCourses } from './data/courses.ts'
const [courses, setCourses] = useState(initialCourses)

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
}
const emptyForm: CourseFormState = {
  title: '',
  description: '',
  category: 'Frontend',
  level: 'Beginner',
  isFavorite: false,
}

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

const levels: CourseLevel[] = [
  'Beginner',
  'Intermediate',
  'Advanced',
]
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 })
  }
}
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 })
  }
}
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.',
  )
}
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>
)
<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>
<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>
<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>
)}

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'
function handleAddCourse(newCourse: Course) {
  setCourses([...courses, newCourse])
}
import type { Course, CourseLevel } from './types/course.ts'
<AddCourseForm onAdd={handleAddCourse} />
.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;
}
.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 form, blank and waiting — every control controlled.
Ble divv, jlizq ahm koohunm — anoqd hucskef racygajpav.

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.
Bayefuweey: ueyz meudosm qionx kvekmur aqs zugptodoc zac uvwuvlode hukkwapacy.

Success: the form resets, the status announces and the catalog grows by one card.
Fejxegq: jvu qasm fugacm, hwo hnanit uyjiojfed ujy xde pofifod qnadd pc uze vubc.

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.

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?

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.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now