Back to path
IntermediatePulse · Project 6 of 12 ~6h· 5 milestones

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.

debouncing user inputAbortController request cancellationTanStack Query cancellation and race safetyARIA combobox patternroving active-descendant keyboard navaria-live announcementsdiscriminated union UI stateaccessible naming for form controls

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:

src/features/events/useDebouncedValue.tsts
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. 1

    Model search state as one status union

    3 guided steps

    Booleans 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. 2

    Debounce typing and make the fetch race-safe with AbortController

    4 guided steps

    Without 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. 3

    Turn the plain input and list into an ARIA combobox

    3 guided steps

    A 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. 4

    Add ArrowUp/ArrowDown/Enter/Escape keyboard navigation

    4 guided steps

    The 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. 5

    Announce result counts and polish empty, loading, and error states

    4 guided steps

    Sighted 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

3 starter files, ready to clone
5 guided milestones
5 full reference solutions
8 code blocks explained line-by-line
5 "is it working?" checks
4 interview questions it prepares you for

You'll walk away with

A debounced search input wired to /api/events with AbortController-safe TanStack Query cancellation
A full ARIA combobox implementation (input + listbox + options) with a real accessible name
Complete ArrowUp/ArrowDown/Enter/Escape keyboard navigation with wraparound
An aria-live region announcing result counts, wired to a single SearchStatus discriminated union
Component and end-to-end tests covering the debounce, the cancellation race, and the keyboard flow

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