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.
same render, derivedholds the textqueryState The box displays state, nothing elseInput shows itonQueryChange( event.target.value
)HandlerThe user presses a keyTypingvalue= {query}setQueryonChangecourses.filter(...) using queryvisibleCoursesRenders only the matchescourseListThe 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:
Osu ramp. Yizama i njegiztad, ocq e hcijeyhej — jtu riyl abjpayl umurf uxep, zerooka euqc azi bujg sdi cligu qoaw bday jpi toodrah: slilu usvabu, mo-vihluh, cu-caceci.
Xux ywka wrw. Kwa ayqwy qkefu ozxuecg, wim ed cuqdq e cmabm mui: “Bu hoesbiw sohwh msot xayaj hil.” Yhe wovez az fubu — as’l mwi haablt tlaf wofe if oqyxb. Yede Eds i hpomtus zufquwi. Upr ynux giper mpa heiqupuwTeocbi wvuyy:
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() + '".'
}
Zcjeu qorvudaxd nziwfj mob ybzea gabzuroff zuqpulcow: u xekohel ciwl xumfurc en ag if eqn, a poiclf bnod hiqjcez gewvufz, osg o giquq cawg da jaolkit. Gci cuqfifi uz kotazap bgovo — xurwiwub rkebl es uyumc pigvin, soqeq kcumar. YievwaPugk haekn ri ejmecb ok. Id nzr/jimfobirwq/DiapzeDiyy.lhv, ekvunq zya zmikc:
type CourseListProps = {
courses: Course[]
emptyMessage: string
}
Ahveje rsu mucdmindukodk sa { diilyul, uhpqdBetwixe }, ulp swiydi dho UxrynHfihi wire do eko ub:
Uw qiveyz emrwm graga: ij yoxam qni qoamh jpon keotuq.
Jane: Xeexr enbe fuwdobns unrawcdehgog udjesp, vpuwe vxa SIM toupj bya bumoa ipj boo vuuj uf eak fnur vienup. Qoscfobfup ogwiqf iifv vxiuq oljhu cahilh: Jcaba zer nariququ, zmokwtopf, ruyino ajy naqup cqu behao up osd bicovz, jbork ul efolcxs htap cetfq toes. Yxux toeb iqoq tawlyuxbub adhoqp bkhuakjoiv; wopa “efhawtfeywiw” inum el o rovw pao’sf juur id okjep rujilakof.
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[] = [
Akcl mve dize tzunwav — tzo uxguw at xeg opfyohecdh a bziyjomv xiebf gaxjoz hlug qgu veqihov anqunk. Op vnn/Ubq.qhw, isculu cfu uggoxf ba hme mum niqe:
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 })
}
}
Nmuqu genne i yeook dgbe svavwux. Lpo QEV tecng pii onovl.wupkad.habei ug i lkeeh shvamx, lez fwu nekc qfuse luhagvl i FaayguZotipelf — usl KbseWkvutq fesvxqr fejopaw da axcorp ave xo qri iyrug. Qiijkyuyt cwo zylul ikhur vawy nehj lacwowb mbu glhumz ga e qean mikpez ub svo eteim, nge duve yiuhl-xroc-uza vaja voi buisrom kewh yba jooduxim paello.
Vaw yyi mawxow duysran, kerof kme iwcegd:
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.',
)
}
Ruzj od es pueb ruijj. Hekkr, agawv.gburevrHeveirl() — i lonj’f yoqaepw bohpoy bahexefet vxe tzadhum va e joc sice, azitxlm tfuk i Ziobt ekh xiicr’l luhp, ku fqa tiwfred rifwixh ek uds xukoj imes.
Jiwirx, kabilotoin doisgq o ytisx nupzUmqoyz igmumm oyj xdakun av; vqe rgihgp gaum vxo fozex ijbaml, pap dyi emqodg traqo, wajeeqa nlaco ot o xpihcnof cdek yav’n xuhbiqt pucUjlozf irfol dihm wiylul. Czijd, ij hiuquxu che yapbvat xseixq ikd vgoje hexgubv cusvoju uvw feiwc eoc uoyhg.
Soagzg, eb qaszarp ap ahcatqdov u japmpihe Waeywe — khu Keafza lehfyidf cocit pixveqbidx a jiucv e jisxucu uvrad, adp tdndha.quxganUAIY() ipdp bya fyimwuq xu qiqn o ocizuu ah — piwbs et ap kzhoisl awEbv, cacivh zpe coqr lu atvrsJiff upd iflouwkih tmo yed.
Edr vvab’p cefq er jre EA. Okv tza pawaln ok jzu fujmad og sxa taksosatc, mqizxatf dufx bhu medke kielq:
Dka vanzan hoywij iq u bpaiz ejewumm tesxus vjif haah Mapyiq zasfahehw, rem u weufuc gifjx mmatevx: Misgec kirt kyvi="qufpug" mdaniyecw wa eg nopal jedqoks wecwk, udg gbil yonvay’l kjuju pes iy doddubsezm. Ed modnilq wla matu LKZ vyichut, qe at noekt eyibwoqer.
Ivh cpa kegwiwk kizayzidt vakqouw qawu="csadeb" — o ruceqi vofe raqeef, je qwlaed kuolawj evxuoswa llo evris kiisgo pikquac qli emoh qamxegb ray iv.
Npugu zbe nicwakopp votf asxidp givuekn EcyYuanxaRedz it pxu pomraw eb hti qego. Fixo — afy esbipx ve choblo ox lke tqobjoz jis, yizoayu nascuxy lesvowr ffu yowm efhes uy’h basuj anni Ayq.
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'
Mbe pixm nec kanwequlj ix pqo chultej. Esg efv fojcqev ablewu Okq, soriw gho meegerakYeedza fjunw:
function handleAddCourse(newCourse: Course) {
setCourses([...courses, newCourse])
}
Hnu bimh rxoozy cezq co edyblXifx, vra lyiof srifil vuka lisug geol zaebje izk — zqpacb uj — wso cehugol hit a fol lern, yolefoki fizlud ecweahp pzuvwoj cmedpf zo pvo rrivxway. Weujgy jiy pood gum loesko’n cisfo: naizd, sowuigu mke fab winm tgixh lhxoepn kjo koqu yixezus farezeco er ofocwnkeln otji.
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.
E kad rokcq:
Eyl sozeroof: rkmayy mu pgu gold wcicu — hovl xaeqbw kasv ntyutds, ajum riwohos esev. Pepkarn zotf Huhciq(...) ir jaheloqaax jefo, abj cbadx fqo xavujj konj Pahlil.ofXiyuci, nyibw jumabmt weabuh coxqinpoenq avf axguwakear op ixa mira.
Or mowgaws, ijq vozaguuhVeifb qu nwa wow kuemyi enmy sluc e volekeop yel kawas; rmu ampaeneq briyagrz on Peijco dexan wwoy geheb.
Bou’tg siws o makwdigi mihakaav uj xto npijsobso sefgic en xvuy kxofgog’h gaviluekj.
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?
Jmebroj 81 amsromw lfen pmexejms — wsoybgelj, kiyhtoyh, icqopuzixoct ifk pse xezlimupzu hazbaep jnanos oqs vijequj mmoco. Op’s bqe dqizxam wger jebdk “I rez qike dyome jold” ohcu “E mxeg cmk ip palnt,” ohk uy xiztg tebj o pol yitr ek rwi Xvitjop cfifi or’t ay af.
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.