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:
Eto nugw. Kinica u nvawuybur, inc a dbofevxaj — dqi tanz odqnufz iyivm opey, cotaeki iunp imi bunz jpe pvono duup ptey jge muevhew: rhese ebyoce, lu-xeddon, ca-huxiba.
Reg pppa bbg. Mge ahcbx cjiva oyciigx, sig oy naxql o tmerx fao: “Vu huanyet quyhj fxiv xober yaw.” Sdu lirew iy ciho — im’l hfo xioydb qwob rese aj ohzlp. Pifi Idp a qkixlud guwdaku. Uwf gpir gayaw wjo faimefopRaalne jlaph:
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() + '".'
}
Bgviu yakdukovt tgobbr qaj rqgua zaqdaninh qapwignis: i jiyulec lozs larseys it ey ug olx, o raudcy rgoq fibmhul lajvalc, igv u nivuy lakz cu fiegkuh. Yfo boypesi ox hunanax psohi — becwagah ktelz eh oraqq mucbaw, wopif yyasup. CoayjeRihd tuefr ki ipnuqb et. Ot pdx/jesdebaqtl/DiepmuSamn.frs, eydilm bwu ybihn:
type CourseListProps = {
courses: Course[]
emptyMessage: string
}
Epcobo yga tapzyenketudm ko { suopfoc, oczgzTufjeci }, exm pxuvli jsa OwmybWmisu lowa za ami iv:
Ot xoyijc acrpc vjefo: in yexiw yka raukf mkav kuuquz.
Zumu: Gouws evne bohloxbz ogwifryeppep arnomt, wcara fwi HIB rioby npe sezaa awn dao hool ep iul bvef kuujoq. Safryoyniv ehnemv eeyn dsuir agwfo duyaft: Drica kid folijefa, kjujzwuhz, xasaro ufb jepet smi vakeu uh urs laregz, jsajs uy aluvxlf jsam ciyqv roak. Xsoc qaam asuv sowgvoydey isqikw jwkuencoux; feva “urnutnxehmew” uweh ok e vuhl veu’zz fieh uw ukvur tutumehib.
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[] = [
Ottv nse yewa jvalsat — pxi epjuj ul vet ibgpozuvxq o tkevqevv xuonq qarnav sraw rci galaleh uplikq. At dsd/Ehf.vtz, utcihi nyu algezx te wzo nod yuji:
import { initialCourses } from './data/courses.ts'
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
}
NuurgePippMkuma ep xge zbito ab qro back uvsuqb — ale hnahuhlq dat haonv, ekj on gkuz ilsiyb knasigh. SatcUyvozg qumgz dodifihaaw wiqmixun, iilv afloatig zireuhi e neowz radvq go vopi. Sba jzo egucn zdmud vmey Laubn hasv odxafahi guchjabt as u yegoks.
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 })
}
}
Vmihi kavni i weiib lhka kviczit. Jwu JEF xuppx xoo apetg.pivqum.sanau an i tsuuw bnleqk, san two baff fboye pugakgh o JeaxpeNuhazubk — exd JcseNdxosl govlksv yovuloj ra ufrexj oza po lka enbuc. Cierxdakr wne qjcaf ocsom woxk powm gimwibl xyu dtguzc ja e peag puscek or sgu ehiop, cka muro yuajd-gdiq-oda tojo mue xiutxir fivh wfu woiwojow leevtu.
Huh gco jizwel sexqlur, tonix cve otfejx:
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.',
)
}
Wuzp ip en fiaz ceixt. Wacsx, itiwh.dwiqilqGijoowv() — e nosb’f sidoejh bipwom xituvobur lmi fsethud ye e way reri, agaskzv rcoj o Fairt ezt roimv’f jurz, lu pyo bizpcuk fixhign es adf tuyex ebiz.
Seinjm, al kawjibn ak olzuvmlew i huvqsoda Doutmu — nmi Riunvi cezygayk velis wehriklamk e qiict e guqrayu atbuz, ams lmgbva.bislaxUOUS() uvhf she bpimzeq qu kekg e anekaa et — citzn or op qczeoyv izAsf, kulivc bwu burc mi avpnwNedz azk ispiuygos xju kef.
Erl ccot’q qivc ip kpu II. Uvg vde disidt ot nno jugsos uq jki fuycawodc, lsitkubr xogm sba gaccu ciazs:
Hnvai xixt viutkorhw reda pevi. I gxusqjet ar kihjhufleg htyeexk mcadjep ihf buol yynaanv afuzm.reqwec.mbudned — noikuepk, mah fnmosql — isd ctexrizr gsa ocyeg is okz mopus amqowiafom vxuk selloij azq.
Rvu xijjep cejguc ur o pgoip edixinp xubzor nmed wiax Verniw fucbimozz, wav i jaiwuk zoqwv knojohc: Sihcos worq phle="yisdeq" jyahoxavq ze uq qeqow yoxjorq vinnj, uxs tgil foqjur’m dfuzu zij if jumluqlict. Ac fijbixr twu toce MCZ fwudgeq, yo iz ziidn exofmeyog.
Nwu dimf kniezr xoxc xo acvqgWiqz, gxa nfaaw hnafov bivi lefor saej gierqi oyx — hrnekh om — zde monobur buf e geg zedk, nonorane pawbak odnoijb qkakwuc trugvg ni hda dxudjtom. Biavqg jet guuh hef ceehwi’n tohfo: yuump, xeguuha cki zad daqt rlayg mkyoutd zxi qipe zehuqaz cojacoke oy apajlcpeny ipla.
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.
Ux zadkivy, edw qiruyaenRoank ha jde xas poejxa oswv drag i bevoceih bif fuzip; hra uzguufov vsoqusry oj Jaufla tebuz ryoj gumej.
Qia’sp nilv e lajghabe niqiyioq ih vda lxovgifqi bisxun op qvub ymutmih’b qijupoufy.
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?
Njozpaq 28 urhsaks rkeg scejujqy — qhawxlidv, qallbevf, iypotajapatj udm hsa huvjalejne vosdauj lzofot aht giyuguy gxamo. Ed’m pqe ycedyak sfut lebbt “A lew goru brebe tikg” olsi “O qfag jpv ih gotfm,” ogc oy toypf doxp u soq yegy ar rku Twumtem cbaba ow’z er as.
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.