455 lines
14 KiB
HTML
455 lines
14 KiB
HTML
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Debounced search — three approaches</title>
|
|
<style>
|
|
:root {
|
|
--ivory: #FAF9F5;
|
|
--slate: #141413;
|
|
--clay: #D97757;
|
|
--oat: #E3DACC;
|
|
--olive: #788C5D;
|
|
--gray-150: #F0EEE6;
|
|
--gray-300: #D1CFC5;
|
|
--gray-500: #87867F;
|
|
--gray-700: #3D3D3A;
|
|
--white: #FFFFFF;
|
|
|
|
--serif: ui-serif, Georgia, 'Times New Roman', serif;
|
|
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
--mono: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: var(--sans);
|
|
background: var(--ivory);
|
|
color: var(--gray-700);
|
|
line-height: 1.55;
|
|
padding: 56px 32px 96px;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
.page {
|
|
max-width: 1360px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* ---------- header ---------- */
|
|
|
|
header.page-head {
|
|
margin-bottom: 48px;
|
|
max-width: 760px;
|
|
}
|
|
|
|
.eyebrow {
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--gray-500);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
h1 {
|
|
font-family: var(--serif);
|
|
font-weight: 500;
|
|
font-size: 38px;
|
|
line-height: 1.15;
|
|
color: var(--slate);
|
|
margin-bottom: 18px;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.prompt-box {
|
|
background: var(--gray-150);
|
|
border: 1.5px solid var(--gray-300);
|
|
border-radius: 12px;
|
|
padding: 16px 20px;
|
|
font-size: 14.5px;
|
|
color: var(--gray-700);
|
|
}
|
|
|
|
.prompt-box .label {
|
|
font-family: var(--mono);
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--gray-500);
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
/* ---------- approach grid ---------- */
|
|
|
|
.approaches {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 28px;
|
|
margin-bottom: 56px;
|
|
}
|
|
|
|
@media (max-width: 1100px) {
|
|
.approaches { grid-template-columns: 1fr; }
|
|
}
|
|
|
|
.approach {
|
|
background: var(--white);
|
|
border: 1.5px solid var(--gray-300);
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.approach-head h2 {
|
|
font-family: var(--serif);
|
|
font-weight: 500;
|
|
font-size: 21px;
|
|
color: var(--slate);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.approach-head .num {
|
|
display: inline-block;
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
background: var(--oat);
|
|
color: var(--slate);
|
|
padding: 2px 8px;
|
|
border-radius: 8px;
|
|
margin-right: 8px;
|
|
vertical-align: 3px;
|
|
}
|
|
|
|
.approach-head p {
|
|
font-size: 14px;
|
|
color: var(--gray-500);
|
|
}
|
|
|
|
/* ---------- code panel ---------- */
|
|
|
|
.code {
|
|
background: var(--slate);
|
|
border-radius: 12px;
|
|
padding: 18px 20px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.code pre {
|
|
font-family: var(--mono);
|
|
font-size: 12.5px;
|
|
line-height: 1.65;
|
|
color: #E8E6DE;
|
|
white-space: pre;
|
|
}
|
|
|
|
.code .kw { color: var(--clay); } /* keywords */
|
|
.code .str { color: var(--olive); } /* strings */
|
|
.code .cm { color: var(--gray-500); } /* comments */
|
|
.code .fn { color: #C9B98A; } /* identifiers, subtle warm */
|
|
|
|
/* ---------- tradeoffs table ---------- */
|
|
|
|
.tradeoffs {
|
|
border: 1.5px solid var(--gray-300);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.tradeoffs .row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
|
|
.tradeoffs .row + .row {
|
|
border-top: 1.5px solid var(--gray-300);
|
|
}
|
|
|
|
.tradeoffs .cell {
|
|
padding: 10px 14px;
|
|
}
|
|
|
|
.tradeoffs .cell:first-child {
|
|
border-right: 1.5px solid var(--gray-300);
|
|
}
|
|
|
|
.tradeoffs .head {
|
|
background: var(--gray-150);
|
|
font-weight: 600;
|
|
color: var(--slate);
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.tradeoffs .pro::before,
|
|
.tradeoffs .con::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
margin-right: 8px;
|
|
vertical-align: 2px;
|
|
}
|
|
.tradeoffs .pro::before { background: var(--olive); }
|
|
.tradeoffs .con::before { background: var(--clay); }
|
|
|
|
/* ---------- chip footer ---------- */
|
|
|
|
.chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.chip {
|
|
font-family: var(--mono);
|
|
font-size: 11.5px;
|
|
background: var(--gray-150);
|
|
border: 1.5px solid var(--gray-300);
|
|
color: var(--gray-700);
|
|
padding: 5px 10px;
|
|
border-radius: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.chip strong { color: var(--slate); font-weight: 600; }
|
|
|
|
/* ---------- recommendation ---------- */
|
|
|
|
.reco {
|
|
border-left: 4px solid var(--clay);
|
|
background: var(--white);
|
|
border-radius: 0 12px 12px 0;
|
|
padding: 24px 28px;
|
|
max-width: 860px;
|
|
}
|
|
|
|
.reco h2 {
|
|
font-family: var(--serif);
|
|
font-weight: 500;
|
|
font-size: 22px;
|
|
color: var(--slate);
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.reco p {
|
|
font-size: 15px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.reco code {
|
|
font-family: var(--mono);
|
|
font-size: 0.92em;
|
|
background: var(--gray-150);
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
|
|
<header class="page-head">
|
|
<div class="eyebrow">Exploration · Acme web client</div>
|
|
<h1>Three ways to implement debounced search</h1>
|
|
<div class="prompt-box">
|
|
<span class="label">Prompt</span>
|
|
Show me three different ways to implement debounced search for the task
|
|
filter input in our React codebase, with tradeoffs for each.
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ============================================================= -->
|
|
|
|
<section class="approaches">
|
|
|
|
<!-- ---------- Approach 1 ---------- -->
|
|
<article class="approach">
|
|
<header class="approach-head">
|
|
<h2><span class="num">01</span>Inline useEffect + setTimeout</h2>
|
|
<p>Debounce logic lives directly inside the component that owns the input.</p>
|
|
</header>
|
|
|
|
<div class="code"><pre><span class="kw">export function</span> <span class="fn">TaskSearch</span>() {
|
|
<span class="kw">const</span> [draft, setDraft] = <span class="fn">useState</span>(<span class="str">''</span>);
|
|
<span class="kw">const</span> [query, setQuery] = <span class="fn">useState</span>(<span class="str">''</span>);
|
|
|
|
<span class="fn">useEffect</span>(() <span class="kw">=></span> {
|
|
<span class="kw">const</span> id = <span class="fn">setTimeout</span>(() <span class="kw">=></span> setQuery(draft), <span class="str">300</span>);
|
|
<span class="kw">return</span> () <span class="kw">=></span> <span class="fn">clearTimeout</span>(id);
|
|
}, [draft]);
|
|
|
|
<span class="kw">const</span> { data } = <span class="fn">useTasks</span>({ search: query });
|
|
|
|
<span class="kw">return</span> (
|
|
<<span class="fn">input</span>
|
|
value={draft}
|
|
onChange={(e) <span class="kw">=></span> setDraft(e.target.value)}
|
|
placeholder=<span class="str">"Filter tasks…"</span>
|
|
/>
|
|
);
|
|
}</pre></div>
|
|
|
|
<div class="tradeoffs">
|
|
<div class="row head">
|
|
<div class="cell">Pro</div>
|
|
<div class="cell">Con</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">Zero new abstractions to learn</div>
|
|
<div class="cell con">Logic duplicated everywhere search exists</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">Easy to step through in devtools</div>
|
|
<div class="cell con">Two pieces of state for one conceptual value</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">No dependency or bundle change</div>
|
|
<div class="cell con">Delay constant is buried in component body</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chips">
|
|
<span class="chip">Bundle impact: <strong>+0 kb</strong></span>
|
|
<span class="chip">Testability: <strong>medium</strong></span>
|
|
<span class="chip">Reuse: <strong>low</strong></span>
|
|
<span class="chip">SSR safe: <strong>yes</strong></span>
|
|
</div>
|
|
</article>
|
|
|
|
<!-- ---------- Approach 2 ---------- -->
|
|
<article class="approach">
|
|
<header class="approach-head">
|
|
<h2><span class="num">02</span>Custom useDebounce hook</h2>
|
|
<p>Extract the timer into a shared hook under <code style="font-family:var(--mono)">src/hooks/</code>.</p>
|
|
</header>
|
|
|
|
<div class="code"><pre><span class="cm">// src/hooks/useDebounce.ts</span>
|
|
<span class="kw">export function</span> <span class="fn">useDebounce</span><T>(value: T, ms = <span class="str">300</span>): T {
|
|
<span class="kw">const</span> [debounced, setDebounced] = <span class="fn">useState</span>(value);
|
|
<span class="fn">useEffect</span>(() <span class="kw">=></span> {
|
|
<span class="kw">const</span> id = <span class="fn">setTimeout</span>(() <span class="kw">=></span> setDebounced(value), ms);
|
|
<span class="kw">return</span> () <span class="kw">=></span> <span class="fn">clearTimeout</span>(id);
|
|
}, [value, ms]);
|
|
<span class="kw">return</span> debounced;
|
|
}
|
|
|
|
<span class="cm">// TaskSearch.tsx</span>
|
|
<span class="kw">const</span> [draft, setDraft] = <span class="fn">useState</span>(<span class="str">''</span>);
|
|
<span class="kw">const</span> query = <span class="fn">useDebounce</span>(draft, <span class="str">300</span>);
|
|
<span class="kw">const</span> { data } = <span class="fn">useTasks</span>({ search: query });</pre></div>
|
|
|
|
<div class="tradeoffs">
|
|
<div class="row head">
|
|
<div class="cell">Pro</div>
|
|
<div class="cell">Con</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">Single import reused across filter, command bar, board search</div>
|
|
<div class="cell con">One more file to maintain and document</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">Trivial to unit test with fake timers</div>
|
|
<div class="cell con">Generic <code style="font-family:var(--mono)">T</code> hides intent slightly</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">Delay is a visible, tunable argument</div>
|
|
<div class="cell con">Still re-renders on every keystroke</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chips">
|
|
<span class="chip">Bundle impact: <strong>+0.2 kb</strong></span>
|
|
<span class="chip">Testability: <strong>high</strong></span>
|
|
<span class="chip">Reuse: <strong>high</strong></span>
|
|
<span class="chip">SSR safe: <strong>yes</strong></span>
|
|
</div>
|
|
</article>
|
|
|
|
<!-- ---------- Approach 3 ---------- -->
|
|
<article class="approach">
|
|
<header class="approach-head">
|
|
<h2><span class="num">03</span>Tiny external library</h2>
|
|
<p>Adopt <code style="font-family:var(--mono)">use-debounce</code> for both values and callbacks.</p>
|
|
</header>
|
|
|
|
<div class="code"><pre><span class="kw">import</span> { useDebouncedCallback }
|
|
<span class="kw">from</span> <span class="str">'use-debounce'</span>;
|
|
|
|
<span class="kw">export function</span> <span class="fn">TaskSearch</span>() {
|
|
<span class="kw">const</span> [query, setQuery] = <span class="fn">useState</span>(<span class="str">''</span>);
|
|
|
|
<span class="kw">const</span> onChange = <span class="fn">useDebouncedCallback</span>(
|
|
(next: <span class="kw">string</span>) <span class="kw">=></span> setQuery(next),
|
|
<span class="str">300</span>,
|
|
{ leading: <span class="kw">false</span>, maxWait: <span class="str">1000</span> },
|
|
);
|
|
|
|
<span class="kw">const</span> { data } = <span class="fn">useTasks</span>({ search: query });
|
|
|
|
<span class="kw">return</span> (
|
|
<<span class="fn">input</span>
|
|
defaultValue=<span class="str">""</span>
|
|
onChange={(e) <span class="kw">=></span> onChange(e.target.value)}
|
|
/>
|
|
);
|
|
}</pre></div>
|
|
|
|
<div class="tradeoffs">
|
|
<div class="row head">
|
|
<div class="cell">Pro</div>
|
|
<div class="cell">Con</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">leading / trailing / maxWait handled for us</div>
|
|
<div class="cell con">New runtime dependency to audit and update</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">Callback form skips intermediate re-renders</div>
|
|
<div class="cell con">Uncontrolled input diverges from Acme form patterns</div>
|
|
</div>
|
|
<div class="row">
|
|
<div class="cell pro">Well-tested edge cases (unmount, flush, cancel)</div>
|
|
<div class="cell con">~1.4 kb gzipped for something we could own</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chips">
|
|
<span class="chip">Bundle impact: <strong>+1.4 kb</strong></span>
|
|
<span class="chip">Testability: <strong>high</strong></span>
|
|
<span class="chip">Reuse: <strong>high</strong></span>
|
|
<span class="chip">SSR safe: <strong>yes</strong></span>
|
|
</div>
|
|
</article>
|
|
|
|
</section>
|
|
|
|
<!-- ============================================================= -->
|
|
|
|
<aside class="reco">
|
|
<h2>Recommendation</h2>
|
|
<p>
|
|
Go with <strong>approach 02, the custom <code>useDebounce</code> hook</strong>.
|
|
Acme already has three places that hand-roll the inline pattern
|
|
(task filter, command palette, member picker), so extracting one
|
|
shared hook removes duplication without taking on a new dependency.
|
|
</p>
|
|
<p>
|
|
Revisit approach 03 only if we later need <code>maxWait</code> or
|
|
<code>flush()</code> semantics — the library earns its bundle cost
|
|
once the requirements outgrow a ten-line hook.
|
|
</p>
|
|
</aside>
|
|
|
|
</div>
|
|
</body>
|
|
</html>
|