Skip to content

Repository files navigation

Debounce Search

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.


Demo

🚧 TODO: Deploy to Vercel and add live URL here.


Screenshots

🚧 TODO: Add screenshots of idle state, loading state, results, error state, and empty state.


Features

  • 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 guardlastFetchedQueryRef prevents re-fetching a query that just resolved
  • Full async state machineidle | loading | success | error states handled explicitly
  • Quick-select hints — clickable category badges to bootstrap the search experience
  • Accessible UIaria-live, aria-busy, role="alert", aria-hidden on 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

Tech Stack

Frontend

Backend

State Management

  • Custom hook (useDebounceSearch) — no external state library
  • useRef for mutable values that should not trigger re-renders (AbortController, last query)
  • Module-level MemoryCache singleton — shared across renders, survives re-mounts

Testing

Tooling & DevOps

  • 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

Architecture

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   │
                          └────────────────┘

Project Structure

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

Main Technical Decisions

Why a custom debounce utility instead of useDebounce?

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.

Why AbortController instead of ignoring stale responses?

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.

Why a custom MemoryCache instead of a library like SWR or React Query?

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.

Why Zod v4?

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.

Why Biome instead of ESLint + Prettier?

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.

Why useRef for the AbortController?

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.


Performance

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

Security

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.


Scalability

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 only useDebounceSearch.ts
  • Add server-side caching — Next.js fetch cache or unstable_cache for the API route
  • Add pagination — extend SearchResponse with page and hasMore; the URL builder already uses URLSearchParams
  • Persist search in URL — replace useState with useSearchParams + router.push for shareable URLs
  • Add search index — for large datasets, replace .filter() with Fuse.js or a vector search backend
  • Extract MemoryCache to a shared package — the class has no React dependency; it's ready to live in a monorepo lib

Getting Started

Requirements

  • Node.js ≥ 20
  • pnpm ≥ 9

Install

pnpm install --ignore-scripts

Run locally

pnpm dev

Open http://localhost:3000.

Production build

pnpm build
pnpm start

Run tests

pnpm test              # run once
pnpm test:watch        # watch mode
pnpm test:coverage     # with coverage report

Lint & format

pnpm lint              # check + autofix
pnpm format            # format only

API

GET /api/search

Searches 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.


Testing

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.


Improvements Roadmap

  • 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 keyboard navigation for result cards
  • Dark mode toggle (CSS vars are already defined)
  • URL search persistence — ?q=react in the address bar
  • Pagination — limit parameter is already accepted by the API
  • Throttle mode as an alternative to debounce (toggle in UI)
  • @vitest/coverage-v8 coverage 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-Policy and security headers via next.config.ts

Lessons Learned

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.


Why This Project Matters

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.


Scores

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages