A focused engineering exercise demonstrating production-grade async patterns in a real-time search interface: debounce, request cancellation via AbortController, client-side TTL caching, and race-condition safety — built with Next.js 15 App Router, React 19, and TypeScript.
🚧 TODO: Deploy to Vercel and add live URL here.
🚧 TODO: Add screenshots of idle state, loading state, results, error state, and empty state.
- 300ms debounced search — fires only after the user stops typing, not on every keystroke
- AbortController — every new request cancels the previous in-flight one, eliminating race conditions
- Client-side TTL cache — identical queries within 60 seconds are served from memory with zero network overhead
- Deduplication guard —
lastFetchedQueryRefprevents re-fetching a query that just resolved - Full async state machine —
idle | loading | success | errorstates handled explicitly - Quick-select hints — clickable category badges to bootstrap the search experience
- Accessible UI —
aria-live,aria-busy,role="alert",aria-hiddenon decorative icons - Zod-validated API — server-side input validation with structured 400 error responses
- Pre-commit enforcement — Husky + lint-staged runs Biome on every staged file before commit
- Next.js 16 — App Router, Server Components, API Routes
- React 19 —
useCallback,useMemo,useRef,useEffect - TypeScript 5 — strict mode, path aliases, generic types
- Tailwind CSS v4 — CSS-first config,
oklchcolor system - shadcn/ui — copy-owned components backed by Base UI primitives
- Lucide React — icon set
- Next.js API Routes —
GET /api/search - Zod v4 — runtime validation of query parameters
- Custom hook (
useDebounceSearch) — no external state library useReffor mutable values that should not trigger re-renders (AbortController, last query)- Module-level
MemoryCachesingleton — shared across renders, survives re-mounts
- Vitest — test runner with native ESM and Vite integration
- React Testing Library — hook and component testing
- jsdom — browser environment simulation
- @testing-library/jest-dom — custom matchers
- pnpm — fast, disk-efficient package manager
- Biome v2 — single-binary linter + formatter replacing ESLint + Prettier
- Husky — Git hooks
- lint-staged — runs Biome only on staged files
This project follows Feature-Based Architecture with a thin layer of Clean Architecture separation between infrastructure (HTTP, cache) and domain (search types, business logic).
┌─────────────────────────────────────────────┐
│ Next.js App Router │
│ page.tsx │
└────────────────────┬────────────────────────┘
│
┌──────────▼──────────┐
│ SearchContainer │ ← Composition root
│ (Client Component) │ no business logic
└──────────┬──────────┘
│ uses
┌──────────▼──────────┐
│ useDebounceSearch │ ← All async logic lives here
│ (custom hook) │
└──┬──────────────────┘
│
┌────────┴──────────────────────┐
│ │
┌───▼──────┐ ┌──────────┐ ┌─────▼──────┐
│ debounce │ │MemoryCache│ │ fetchJson │
│ (util) │ │ (lib) │ │ HttpError │
└──────────┘ └──────────┘ └─────┬──────┘
│ calls
┌────────▼───────┐
│ GET /api/search │
│ (Route Handler)│
└────────┬───────┘
│ validates
┌────────▼───────┐
│ Zod Schema │
└────────────────┘
src/
├── app/
│ ├── api/search/
│ │ ├── route.ts # GET handler — validates, delays, filters
│ │ ├── schemas.ts # Zod schemas for request/response
│ │ └── data.ts # In-memory dataset (20 articles)
│ ├── layout.tsx # Root layout with Geist font
│ └── page.tsx # Entry point — Server Component
│
├── components/
│ ├── SearchContainer.tsx # Composition root — wires hook to UI
│ ├── index.ts # Barrel export
│ └── ui/ # shadcn/ui owned components
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── input.tsx
│ └── separator.tsx
│
├── features/search/ # Self-contained feature slice
│ ├── components/
│ │ ├── SearchHints.tsx # Category quick-select
│ │ ├── SearchInput.tsx # Controlled input with clear button
│ │ ├── SearchResultCard.tsx # Single result item
│ │ ├── SearchResults.tsx # Results list + status composition
│ │ ├── SearchStatus.tsx # Loading / error / empty states
│ │ └── index.ts
│ ├── hooks/
│ │ ├── useDebounceSearch.ts # Core hook with all async logic
│ │ └── index.ts
│ ├── types/index.ts # Re-exports from global types
│ └── index.ts # Public API of the feature
│
├── hooks/
│ ├── useDebounce.ts # Generic value-debounce hook
│ └── index.ts
│
├── lib/
│ ├── cache.ts # MemoryCache<K,V> with TTL
│ ├── http-client.ts # fetchJson<T> + HttpError class
│ ├── utils.ts # cn() — clsx + tailwind-merge
│ └── index.ts
│
├── types/
│ ├── search.ts # SearchResult, SearchResponse, SearchState<T>
│ └── index.ts
│
├── utils/
│ ├── debounce.ts # Pure generic debounce function
│ └── index.ts
│
└── tests/
├── setup.ts
├── hooks/
│ ├── useDebounce.test.ts
│ └── useDebounceSearch.test.ts
├── lib/
│ └── cache.test.ts
└── utils/
└── debounce.test.ts
The imperative debounce() function is used inside useDebounceSearch via useMemo. The alternative — using useDebounce(query, 300) and watching debouncedQuery in a useEffect — has a subtle edge case: if the user clears the input and re-types the same query, debouncedQuery never changes value, so React never re-fires the effect.
By watching the raw query in the effect and using the imperative debounce as a callback, every state transition in query (including clearing and retyping the same string) correctly triggers the search pipeline.
useDebounce remains available as a generic utility for simpler cases where value-debouncing is sufficient.
The common pattern of tracking a request ID and discarding results that don't match still executes all requests. AbortController actually cancels the underlying network request — the browser doesn't even parse the response body — which saves bandwidth and avoids unnecessary server load.
SWR and React Query are excellent, but they introduce a non-trivial amount of abstraction for a pattern this focused. A hand-rolled MemoryCache<K, V> makes the caching logic completely explicit, testable in isolation, and demonstrates the underlying concept rather than delegating it.
The API route validates all incoming query parameters before processing. Zod v4 provides precise, composable schemas with structured error output (.flatten()), making it straightforward to return machine-readable 400 responses that a client could display.
Notable gotcha encountered: URLSearchParams.get() returns null for absent keys — not undefined. Zod's .default() only applies to undefined, not null. The fix was searchParams.get("limit") ?? undefined, which was caught and fixed during development.
A single Rust-based binary replaces two tools, a shared config ecosystem, and the perpetual version conflict between ESLint and its plugins. Biome enforces noUnusedVariables, noUnusedImports, and consistent formatting in a single pass — and runs in ~70ms on this codebase.
useRef values mutate without triggering a re-render. The AbortController itself is infrastructure — React doesn't need to know when it's replaced. Using useState for it would cause an unnecessary re-render on every new request, and including it in a useEffect dependency array would create an infinite loop.
| Optimization | Implementation |
|---|---|
| Debounce (300ms) | Eliminates intermediate requests while the user is typing |
| AbortController | Cancels in-flight requests immediately on new keystrokes |
| TTL Cache (60s) | Zero network requests for repeated queries within the window |
| Deduplication guard | lastFetchedQueryRef prevents re-fetching the same query even if state resets |
useCallback on performSearch |
Stable reference across renders; safe useMemo dependency |
useMemo on debounced function |
Debounce wrapper created once; timer state persists across renders |
| Server Component entrypoint | page.tsx is a Server Component; only SearchContainer and below are client-side |
| Incremental TypeScript | "incremental": true in tsconfig.json — faster type-check on subsequent runs |
| Practice | Details |
|---|---|
| Input validation | All query parameters validated server-side via Zod before any processing |
| Max query length | q capped at 100 characters by Zod schema |
| Structured error responses | API returns 400 with error.flatten() — never leaks stack traces |
autoComplete="off" |
Prevents browser from leaking search history via autocomplete |
spellCheck={false} |
Avoids sending input to third-party spell-check services |
| No secrets in codebase | No environment variables are used; no credentials exist |
aria-busy on input |
Signals screen readers about loading state without exposing internals |
Not implemented (honest): Rate limiting, authentication, CSRF protection, Content Security Policy headers — none of these are needed for a client-side demo with a mock dataset, and claiming otherwise would be misleading.
The current implementation is intentionally focused. To scale this pattern to a production system:
- Replace in-memory cache with Redis — the
MemoryCache<K,V>interface is generic; swapping the implementation requires changing onlyuseDebounceSearch.ts - Add server-side caching — Next.js
fetchcache orunstable_cachefor the API route - Add pagination — extend
SearchResponsewithpageandhasMore; the URL builder already usesURLSearchParams - Persist search in URL — replace
useStatewithuseSearchParams+router.pushfor shareable URLs - Add search index — for large datasets, replace
.filter()with Fuse.js or a vector search backend - Extract
MemoryCacheto a shared package — the class has no React dependency; it's ready to live in a monorepo lib
- Node.js ≥ 20
- pnpm ≥ 9
pnpm install --ignore-scriptspnpm devOpen http://localhost:3000.
pnpm build
pnpm startpnpm test # run once
pnpm test:watch # watch mode
pnpm test:coverage # with coverage reportpnpm lint # check + autofix
pnpm format # format onlySearches the in-memory article dataset.
Query Parameters
| Parameter | Type | Required | Default | Constraints |
|---|---|---|---|---|
q |
string |
✅ | — | min 1, max 100 chars |
limit |
number |
❌ | 10 |
integer, 1–50 |
Success Response 200 OK
{
"data": [
{
"id": 1,
"title": "React 19 new features",
"body": "Exploring the concurrent rendering improvements...",
"userId": 1,
"category": "Frontend"
}
],
"total": 1,
"query": "react"
}Error Response 400 Bad Request
{
"error": "Invalid query parameters",
"details": {
"fieldErrors": { "q": ["String must contain at least 1 character(s)"] },
"formErrors": []
}
}Search behavior: case-insensitive substring match against title, body, and category. Includes a 400ms artificial delay to simulate real network latency.
Strategy: test behavior, not implementation. Each test suite covers a single module in isolation.
| Suite | File | Tests | What it covers |
|---|---|---|---|
debounce |
utils/debounce.test.ts |
5 | Timer reset, single-fire, argument passing, subsequent calls |
MemoryCache |
lib/cache.test.ts |
8 | get/set, TTL expiry, boundary conditions, has(), clear(), size |
useDebounce |
hooks/useDebounce.test.ts |
5 | Value debouncing, rapid updates, generic types |
useDebounceSearch |
hooks/useDebounceSearch.test.ts |
7 | Full async lifecycle: idle → loading → success/error, cache hit, deduplication |
Total: 25 tests, 4 suites — all passing.
Fake timers (vi.useFakeTimers) are used throughout to make async timing tests deterministic and fast. The module-level cache is cleared in beforeEach / afterEach to prevent test contamination.
- Deploy to Vercel with live demo URL
- Add screenshots / screen recording to README
- GitHub Actions CI — run tests + Biome check on every PR
- Component tests for
SearchInput,SearchResults,SearchStatus - E2E tests with Playwright — full user flow from typing to results
- Add
keyboardnavigation for result cards - Dark mode toggle (CSS vars are already defined)
- URL search persistence —
?q=reactin the address bar - Pagination —
limitparameter is already accepted by the API - Throttle mode as an alternative to debounce (toggle in UI)
-
@vitest/coverage-v8coverage thresholds in CI - Docker support for local development
- Replace in-memory data with a real data source
- Rate limiting on the API route
-
Content-Security-Policyand security headers vianext.config.ts
1. null vs undefined in Zod v4
URLSearchParams.get() returns null, not undefined. Zod's .default() only activates on undefined. The fix (?? undefined) is one character, but missing it silently breaks validation for optional parameters.
2. The danger of watching derived state in useEffect
Using useEffect([debouncedQuery]) fails when the user clears the input and re-types the same string — the derived value doesn't change, so the effect never re-fires. Watching the raw input and keeping debouncing as a callback preserves the correct behavior for all edge cases.
3. undefined is the correct "empty" value for setTimeout return
Initializing timer as null forced an if (timer !== null) guard before clearTimeout. Typing it as ReturnType<typeof setTimeout> | undefined makes clearTimeout(timer) valid in all cases — clearTimeout(undefined) is a documented no-op.
4. Module-level singletons in tests
A module-level cache shared across tests will contaminate later test runs with earlier results. Exposing clearSearchCache() for test setup/teardown is a pragmatic solution, but a cleaner alternative would be dependency injection — accepting the cache as a parameter.
A recruiter or senior engineer reviewing this repository would observe:
Async correctness under pressure. The combination of debounce, AbortController, cache, and deduplication guard solves four distinct failure modes of naive search implementations. Each is present and handled deliberately.
Generic, reusable primitives. MemoryCache<K, V>, debounce<T>, SearchState<T>, and fetchJson<T> are all fully generic. They are not coupled to the search feature — they could drop into any project.
No magic, no unnecessary abstractions. No React Query, no SWR, no Zustand. The state machine is explicit, the cache logic is readable in 40 lines, and the HTTP layer is trivially swappable.
Tests that prove behavior. The test for "cache hit prevents duplicate request" verifies the deduplication behavior end-to-end, including the edge case of clearing and re-searching the same string.
Attention to the unsexy details. Correct use of aria-live, aria-busy, and role="alert". autoComplete="off" and spellCheck={false} on the search input. Explicit handling of AbortError vs HttpError. The null → undefined Zod fix. These are the details that separate engineers who ship correct software from engineers who ship software that mostly works.
| Category | Score | Notes |
|---|---|---|
| Code Quality | 7 / 10 | Clean, typed, consistent. useDebounce hook exists but is unused in the actual feature. clearSearchCache leaks a test concern into production code — a sign that the cache should be injected, not shared as a module global. |
| Architecture | 7.5 / 10 | Solid feature-based structure with clear boundaries. Hard to evaluate at scale with a single feature. The SearchContainer as composition root is correct. The layer between features/search and components is slightly ambiguous. |
| Scalability | 5 / 10 | In-memory cache dies on page close. No pagination. No URL persistence. No server-side caching. Hardcoded dataset. The patterns are right; the infrastructure is not. |
| Documentation | 2 / 10 | Before this README, it was the default create-next-app boilerplate. No live demo. No screenshots. No ADRs. A GitHub recruiter who opens the repo and sees a stock README closes the tab. |
| Maintainability | 7 / 10 | Good patterns, barrel exports, strict TypeScript. Missing: CI, coverage gates, and next.config.ts is empty. Husky + lint-staged works locally but there is no automated check on PRs. |
| Recruiter Appeal | 5.5 / 10 | The code tells the right story but nothing surfaces it. No deployed demo. No screenshots. The README (before now) communicated nothing about what was built. Engineers at Vercel, Stripe, or Cloudflare look at dozens of repos — a repo with no demo and no README is invisible regardless of what the code says. |