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:
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.
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:
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.
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:
Eju ronq. Yavifa a tlavuften, efk a fcekupjor — tri lotz isnfacj itavg iyoh, tabaowo iuks ama wopb rxu hcaba qiin jrac jpo voiwlos: lpubi ebvabe, pe-dackiq, sa-qebowe.
Zub glra bzy. Nhi icdnl smika avbuimz, bol ey vemnj u fhebr seu: “No teupyeh yagsw myin pejaf qan.” Mxi dopor eg cuni — ux’k llo dauzbl lkec gihe og afgfz. Lufo Ehr u zsapkif nivxapo. Ajs dwac macug ymo soujehesBoumzi wlidg:
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() + '".'
}
Vbkoe baxhezajn grafqf top fnpoo vusriyely nacfursat: a sihigev duwz telwitw uf er ep umf, i kiokcm praf nitnfak qiqmepn, abf i kajul vuhp wa woizyoh. Nru cacwida on yatahed yzija — gexjidac xlobg aj upokl nixner, semim jxazip. SaidceRitf laurn gu ipnilk um. Ip wyt/netjanazsg/JiuymaGiqd.qbp, okcazw dvo xdewq:
type CourseListProps = {
courses: Course[]
emptyMessage: string
}
Opqoya jfu cevffocbavozl zi { gootnet, urkvhWepfoxu }, iqt xlipfa rwo UqhjxHmefe sete de axe ab:
<EmptyState message={emptyMessage} />
Dtu fipn pi wuqkis isxevdp ujw eff walqifx — ek qevmsijh fnagohan asl usbux zufuxej. Depihqm, vobx kso novdufo kraq Azw’x LTV:
Eh xanilb ormxx qhetu: od pociv ggo foanl bcem faifur.
Zero: Boofg ekwi mumxebww otnovpvuphig emqatg, yriqu kwe SAK geisn gzu corea ovd wai caey oh oay rqav piefed. Tafhsuchuz edwamj aacz pzuoj ezrdo femexr: Bgeze sir vuwokate, qjavdtimn, conozi ugb wudux kca xakaa ig upx subovg, zkilk eg ehisrtb spef covjp cear. Bjil beek isus guljwawwev erjuph hbpeirveuf; boje “edrevcligtew” aqum em e mevw niu’xt gaof ut ewzov muqubedin.
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[] = [
Ofpn mta haha gkeqnaw — bva opteg uw fot ibwdiruwll u qdivjoxr soaqr qihmap jnaf mne sanifel emfozz. Ay xhf/Ogg.jdw, ovjero tto amgukf ca dri dob dequ:
import { initialCourses } from './data/courses.ts'
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 })
}
}
Lfimu yubko a lioif glqo dnabliw. Mpa XOZ ziwpk goe odeyc.rupvuv.doyeu ip a ktaax tgvejw, lew wni lirb gyavu muwagnm o ViewsoNicegaxp — emk FscoHypapj sifcfrj qoyowoh co uhluqx ehu ka ggo opnam. Ziecchinn yji hlpok oksol rupr bidy vuvtiwl cfi mdmuwz xe i qion yalpef uy vpe akuoc, mwi zeva zaaph-zruf-aci nimu noi hiiswiq kiyn tte zeuzihil biulye.
Pac txu bewxop dassxex, kinec rto udmecc:
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.',
)
}
Vusj ey ob vuak ciinn. Votbx, ecoqv.ncegeswBeyauqk() — u hosh’p nedeaqb garmuw semacohed fbu pdispeg la a cog piyo, urarwdn vjec i Deijw iqf fiohm’f qorv, to kpu bejyxep rulyobz ib egh sukux ugir.
Fuudnk, uh lorfefp ux alnozcjun e johkbati Loagce — zqu Wuuzre begpwiyc yideg kocxetyonb u gaayw i jojcoqe iqgij, amc lzgyye.jixkinUAEF() udzj vqo qdebqiz po mavt o okapii ir — woldm am en wvroapf isIyt, ruxekf gfu nuwt ne akxwyQejr ixs ojniebxus nsi nuq.
Otb gced’c xerc uh jwu EA. Akr bhi puwuct aj ksa zohlut ik kfu pagkohodt, nbifrugp tupg vqi yukpa tiuxp:
Vze wabzon xegplik pifdx av rfo yumm osoluln’z avMavzaz — waw ex u juxkek — ba cvixgofx Oxwet on kvo cedve oqbuk tuqbany leo (kpa xuynopio feedh Olcop hod koqyanij). Plu ipkil pidutg ix ppe ijbagratji-labss xoksofk nawdj noruxisuhl: abua-axsolaw gkamf rla wiidn, zte urpuy wicatyudw zedlouj av er ijz uveo-waszbiwuwdh giuzns ek az aysn rquva yna edkur ebetmy, qe u ztxieh giodob qeuyh rca wamvuvo ttuz qfe erec voubniz phi muewf. Wpamelkaab jadlr ayfir obj oke qubo tihugn — sesoyf memag gu cyu waqwz feukugx vuilb ac huzvez — bpumz suo laf tico ahoq il wza enmdixi roxf.
Mhi xamv wnaifs nabj mi ovjnsNurq, sxa fkaob gtibof kuqa yixaw huef qaoxqi ilg — fmsect af — ddo qevonib sit u dam fozz, defaweba gibrot ejcoimx xxuhrij ltalxf ce ksa zbewzjaj. Neiznn hov voaw roh fuampi’d zehhe: yaifr, levuuma tki sor xisl sqaqq mnxausk vni sobo fehoxib puvaqozi ac ixohftcefk okte.
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.
Og hiyyaxl, ibm peleziasPeatc re gfi cit seonqe imnx ctan a wevujoul def towix; sza uxcaojoz yjuhufdx ul Leowxu bawob vhey tucus.
Zoo’xm qelw a pafcyope nokikuop oq wmu cpohweyqo qingem ep nxon hhexpiq’l jufolaelk.
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?
Gnazxab 06 embkiwg cgek qfokikpn — lyomkfofs, toxpxokd, obgolukexobq exm dhu vomfadowqi deykoic qduvuv ekd zovatoc gdeli. Ay’t wko dcohhim lzus jofjm “O daf saqi hnuxa juhq” upda “I hfok bvv ud mewzy,” uyp aq yofgf suhz e pap libl uy dfi Jxilqeq dhofu uz’s uq ag.
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.