Build an accessible search and filter
Continues from the last build: a routed app where the events list is unfiltered
Last rung gave Pulse real routes and a code-split bundle, but the events screen still just dumps every event in one long list.
What you'll build
A debounced, race-safe, keyboard-navigable event search built on the ARIA combobox pattern, with aria-live result counts and a single status union driving every visual state.
See how we teach, before you sign up
You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:
import { useEffect, useState } from "react";
/**
* Delays updating the returned value until `value` has stopped
* changing for `delayMs`. Used to turn "one request per keystroke"
* into "one request per pause in typing".
*/
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => {
setDebounced(value);
}, delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
Reading this file
export function useDebouncedValue<T>(value: T, delayMs: number): T {Generic over T so it works for the search string today and any other debounced value later.const timer = window.setTimeout(() => {setTimeout, not setInterval: we only want one fire after the pause, not repeats.return () => window.clearTimeout(timer);The cleanup function cancels the pending timer whenever value changes before it fires, which is what makes this a debounce and not a throttle.
Generic debounce hook, already correct and reusable as-is.
That's 1 of 8 explained code blocks in this single project.
The build, milestone by milestone
- 1
Model search state as one status union
3 guided stepsBooleans combine badly: nothing stops isLoading and isError from both being true at once, and every consumer has to re-derive "what do I actually show" from scratch. A discriminated union makes the compiler enforce exhaustiveness, so a forgotten case is a type error, not a bug found in production.
- 2
Debounce typing and make the fetch race-safe with AbortController
4 guided stepsWithout cancellation, a slow response to an earlier, shorter query (like "ja") can arrive after a faster response to a later, longer query ("jane") and silently overwrite it, showing the wrong results with no visible error. AbortController plus a query key keyed on the search term is what makes "the latest request wins" true instead of "whichever response happens to arrive last wins".
- 3
Turn the plain input and list into an ARIA combobox
3 guided stepsA plain <input> with a <ul> below it looks like a search box visually but announces as nothing meaningful to a screen reader: no relationship between the input and the list, no indication it expands, and often no accessible name at all so it reads as just "edit text". The ARIA combobox pattern is what tells assistive tech "this input controls that listbox".
- 4
Add ArrowUp/ArrowDown/Enter/Escape keyboard navigation
4 guided stepsThe combobox pattern from the previous milestone only announces the active option correctly if keyboard focus actually moves it. Without this, sighted mouse users get a working search box while keyboard and screen reader users get an input that visually shows a list they cannot interact with.
- 5
Announce result counts and polish empty, loading, and error states
4 guided stepsSighted users see the result count change instantly; screen reader users need it spoken, because focus never leaves the input as results update. Without an aria-live region, a screen reader user gets total silence after typing, with no way to know the search even ran.
What's inside when you start
You'll walk away with
This is portfolio-grade. Build it free.
Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.
Start building