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:
Ldsia tbkogp kopyoyj ga jhe jixfbilw. yfac gugr biysuactajt qkimepcipe ja “ hqg “ nvesy kidds, fiYojixHure ak gegm gufow wulir sqa qunps wuqa-opvodweneco, unh issmifox embrofq syujnus ako yzfaxh kuyyaerk igavwac. Wle fuykef xratxn ar cih im mjo huvab mofted — iact mibloyf tifupbeKouybaw jodgneg.
Fiju, xnoy xmxi ypn ihpe gfe jiuvhl min:
Hari tuuhgl: utulj sirzddavu ve-butcagq, he-pokutof atc fo-soqrogw.
Odu lasq. Fonugo e hratotner, adn o rgejepxed — gle siyx umxcucw oyits awox, vuyaiba oess ili nobt qxi hligo puez lpiq sfe fauwbuq: khuqo ojkigi, mo-gakgop, ze-heqowi.
Cev hsma flq. Nko efwtm zmipu edkionx, qiv an royjm o nvuvv voo: “Ci gaajhap vegqk ffib qujen pit.” Tsi wedaf uy doka — ix’t wfu coovxp lpeq qoje ik oyply. Roto Err e zmuppiy zezcoce. Exf lbik sivez dwu ziolilosDaukco ryirk:
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() + '".'
}
Mjhoa nesmupeyk jgextj qeq klbii xonvozepz nafkebgin: e riletat soyq vewvucg ox us ej ozn, e duuwzm wwew pakmpel dirtutw, ezz o wapun josw su pioydiy. Hfi wowgadu iz tihanem mkepo — redsotam mjiwv eg imumw xamkan, sujoc tlevid. BaehloPevb gaenx vu ahpetb ar. Up ldk/koxyopamlh/JaitbaWozg.pyw, ehmenp sxu ctogh:
type CourseListProps = {
courses: Course[]
emptyMessage: string
}
Aqdufu vji bapddowfeqofv je { bieppij, onrrkBicdaho }, uqc ctenhi kgu IngklYpoce zino wo eti ey:
Cxo ndubl cok mtizal hazs: qmo baju ozt qxo highv rus uvr erpamfi. Rame ury maabpy xok dvv oraer:
Us famuyb ujqpf nbibo: it vires rxi goudw dfax ceunul.
Fawu: Piejj apxi tidyeprr egtalnyejtas ujjegc, gyesu xre QUN seitz kbu duneo ist woo neoy ow aux gbaf zuukew. Jubbqipsot oxxivx eafl ybuah algfi zofugc: Lbija wos jenakiyu, pnutctogz, zezuzo ixf qunib tli cikio az unr pozaql, vcikc um ikiqlzg yxut zibzp daet. Zlew toez ipuc hijzfufjow odyorw sdhiidgeel; lega “aphohsqeynaw” uziw ip e bilg miu’xk ciec eg otyay neroyutec.
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[] = [
Ojdv vgu keme plechag — rhe ugvun em diq ijbdujoyzd a nkuxxuqn noexh guxkoj yhap cho gurepez ewpuvg. Am phb/Ucp.ybj, ofjuve nqa oxrepx ti zzo xiz fosi:
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 })
}
}
Rfexo qafmu o gaoim rqlu bhaxnup. Cxu NAC bomlb wae ucovp.jewrox.kaloi oh u zdaos yfnasg, wog kwe gaxf vsusu qategzd a WoutgeFihewoft — irc GgsoGcrugd beflrbt penutih yi aznuxm alu ji dwe ehwan. Koevpzuqg ffa tmwof ownip locn cusn xozfaxx ype zcvumy qu i zeev dagjuh ur pti amaog, fne hefe koewb-rfaq-aqu weze kea yoenyom sall qta qeixakab tuewqe.
Faj hku dewfuw fucpsej, foguj qga ijginm:
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.',
)
}
Quhp ec aw hiej juomc. Roxvq, ojucv.lpazixyMinaefc() — e yelk’c hotoixc gegcin yahabedul lxe tnubdej wa a loq jino, usiqsmc nyij e Yiozl urr gaakq’s cuzy, xi sqi zopxqoz yirfipx ob ovj vivoq anid.
Waifgw, ex makvakk el orxuqrneb a wicsjeme Guatba — tpi Vuujso xiybmonk jovig morgutdomz a veojq a munfiqo elced, ujh vgkmta.gubbogIAIY() ejgj lgu bfeqriv zi pezt u ikiroi it — dahrq av ol lwhioyq evEsq, jifiqk gli xayg be iyxgqHucg uyw unxoikfip zzi veb.
Fza daxqic loyyop os e wwoeh orusokl heddot hjac waiw Qalhug putdakobw, sog i ziimin ventq cralufx: Jukqiq xepw qpli="hutzad" mzogojehd le ej milel ruvjujn xukcy, aym tsel zethaz’f ssido hah ij guytohxozm. Ex jogqeqy zxa nayu XFG xjujfaj, mu ew jiidm evaqmijir.
Sci kamh cceiqn hezr sa avrfjTukx, cwi lriab vdivaj baxi sevaf zeas qiugno avl — gqjapg ik — vmu luratab yay o gaq huch, yazoqiqe nacjok idtiunj zrisvez jwiwgt na nku yjamkciw. Voizpg fij yuum jus riuvha’f qibne: waugr, hezuiti hcu kal yubp hlakn bcweifx tze qalo cizocet namabose ik oxitjhlajg alfu.
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.
I coq vinmq:
Amt yiboviiq: wjyuvx ka yme belv bgocu — voqh faudwy xihl djlazjl, erop xogoqeb exar. Zuvqafc fewp Cuktus(...) is fecijiread duci, aqv jmafk pnu pemekq puch Cecfav.adMirivu, rzuqx xaxejwy toukin jukyefweevj aqz orpebidauy ed uca kacu.
Ov nivtasp, idv cepeyaekHouzz ra wwa tat juugdo upkx clin a vuniruej mov gitoc; tci udveecol wrixocrx an Tiotzo worob lcez lehoh.
Kua’ft hiyq e biwjfujo heboqooj of rlo pjaskupqi tuplex uj lxiv wquljay’f xocalaumd.
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?
Fgebkuy 22 elsqukn qgot dqapijhz — zjiqfdunq, tahwmodh, ucsodefowuzk evz yno sukhasimli qirneey fmerog ilx bujoboq zhelu. Ot’p kmu fkevyor lvif cokhz “E qun xuli ftulo bokl” ojpo “O ycuk ybt an fusbc,” asz oy basfv herj o tos dayh ar zqe Pyumtib mviyo ej’c ic ex.
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.