Lesson 101·M11 Production Concerns
Common Coding Tasks
OpenLearning Objectives
By the end of this lesson you should be able to:
- Implement
useDebounceand wire it into a controlled search input that fetches on the trailing value - Implement
useInfiniteScrollwithIntersectionObserverand say why it beats a scroll listener - Build an accessible modal: portal or fixed overlay, Escape to close, and a minimal focus trap
- Build a controlled tabs component with arrow-key navigation and an
aria-selectedpattern - Build a toast system with an imperative
toast()API, auto-dismiss timers and a context-backed store - Explain the one design decision behind each task and handle the edge cases interviewers probe
What Are Common Coding Tasks?
Small, self-contained UI features — debounced search, infinite scroll, modal, tabs, toast — that interviewers use to watch you code live.
They are the "live-coding round" version of a frontend screen. The scope is intentionally small so the round measures how you build, not how much you memorised. Every task maps to a real product feature, which is exactly why they repeat: one round's modal is the next round's toast. Each is a pattern you can write from muscle memory.
Mental Model
You're a contractor handed five small tickets on day one. Each ticket is a feature, not a task: "add search with debounce", "load more when the user scrolls to the bottom". A senior doesn't build these from scratch every time — they reach for a tiny reusable hook they've written before and adapt it.
That's the entire lesson. The hook is the deliverable, the component is the demo. The component proves the hook works; the hook is what you keep.
Visual Flow
The five tasks, and the one idea each rides on:
Task The one idea The reusable piece
─────────────────────────────────────────────────────────────────────
1. Debounced wait for a pause, then useDebounce(value, delay)
search fetch the trailing value → + useSearch()
─────────────────────────────────────────────────────────────────────
2. Infinite fire when the sentinel useInfiniteScroll(cb)
scroll enters the viewport → IntersectionObserver
─────────────────────────────────────────────────────────────────────
3. Modal escape to close, trap the Modal + useFocusTrap
focus inside the dialog → createPortal
─────────────────────────────────────────────────────────────────────
4. Tabs one index of state, useTabs(count) → keys +
keys for keyboard arrow handling
─────────────────────────────────────────────────────────────────────
5. Toast an imperative API over ToastProvider + useToast
a context-backed store → toast('Saved') anywhere
─────────────────────────────────────────────────────────────────────
Read it as a pipeline: pick the state → put it in a hook → subscribe a component. Task 5 is the same pipeline with the state moved into context so any component can reach it.
How It Works
Everything here is three primitives you already own: state, effects, and refs. The variety comes from how you combine them.
- State drives the UI:
query,page,activeTab,toasts[]. - Effects do the side-effect work: subscribing to observers, starting timers, wiring key listeners.
- Refs hold values that must not trigger renders: the observer, the timer id, the latest query.
The one mental rule that keeps all five tasks clean: if a value must survive renders without causing one, it's a ref; if it must show up in the UI, it's state. Confusing those two is the cause of most bugs in this lesson.
Then two smaller rules do the rest:
- Clean up every effect. Return the unsubscribe/clear function. An infinite scroll that never disconnects its observer, or a toast that never clears its timer, is a leak wearing a feature.
- Every mutation goes through the state setter. In infinite scroll that's the
setItems(prev => [...prev, ...page])append — mutatingitems.push()is Lesson 6's reference trap: the old and new arrays are the same object, and React skips the render.
Real Project Usage
Every one of these ships in production, every day.
| Feature | Where you've used it | The hook it became |
|---|---|---|
| Debounced search | Every search box: product search, Gmail, code search | useDebounce + a fetch effect |
| Infinite scroll | Twitter/X timelines, Instagram, every feed | useInfiniteScroll + IntersectionObserver |
| Modal | Confirm dialogs, login, image lightboxes | Modal + portal + focus trap |
| Tabs | Settings pages, dashboards, pricing tables | useTabs with keyboard support |
| Toast | "Saved", "Copied link", error banners | ToastProvider + toast() |
Now let's build all five, one by one.
Task 1 — Debounced Search
The prompt, verbatim from real rounds: "Build a search box that fetches results only after the user stops typing."
Two decisions to make before writing code. First: controlled or uncontrolled? The input
value needs to drive the search, so it's controlled — value from state, onChange updates
it. Second: what fires the fetch? Two candidates:
- Debounce the input event (
onChange→debounce(fetch)) — the timer is inside the event handler. - Debounce the input value (
useDebounce(value, 300)+ an effect on the debounced value) — the timer is inside the hook.
The second one is the answer interviewers want, because it's testable and it decouples "what's in the box" from "what did we search for". Lesson 18's debounce reference is exactly this: wait 300ms of silence, then act on the trailing value. We wrap that in a hook (from Lesson 65) so any component can use it.
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // cancel the pending update on every change
}, [value, delay]);
return debounced;
}
user types "r" → value="r" → timer A starts → 300ms → debounced="r"
user types "re" (at 100ms) → timer A cleared → timer B starts
user types "rea" (at 200ms) → timer B cleared → timer C starts
(no keystroke for 300ms) → timer C fires → debounced="rea"
That hook is the pattern. Now the component, and note where the effect dependencies come from: we re-run the fetch effect only when the debounced value changes, not on every keystroke. This is the value-debounce win over event-debounce — the effect is trivially correct because its dependency is exactly the thing that should trigger it.
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([]);
return;
}
let cancelled = false; // ignore stale responses
fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`)
.then((r) => r.json())
.then((data) => { if (!cancelled) setResults(data); });
return () => { cancelled = true; };
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products…"
/>
<ul>
{results.map((r) => <li key={r.id}>{r.name}</li>)}
</ul>
</div>
);
}
Render result:
[ search products… ]
• Wireless Mouse
• Mechanical Keyboard
typing updates the input instantly; the list only changes 300ms after you stop.
Tip
Name the two debounce candidates out loud before coding. Saying "I'll debounce the value, not the event, so the effect dependency is the debounced value" is worth more than the code itself.
Task 2 — Infinite Scroll
The prompt: "Load the next page of results when the user scrolls to the bottom."
IntersectionObserver, not a scroll listener. It's the senior answer, and it's honestly
simpler: you attach it to a sentinel element (a tiny div at the bottom of the list) and
get a callback the moment that element enters the viewport — no math, no scroll event, no
getBoundingClientRect. A scroll listener means listening to a high-frequency event,
reading positions, and manually checking the threshold; the observer is declarative and the
browser does the geometry.
function useInfiniteScroll(loadMore) {
const observerRef = useRef(null);
const sentinelRef = useRef(null); // attach this to the bottom element
const loadMoreRef = useRef(loadMore); // see the stable-callback note below
useEffect(() => {
loadMoreRef.current = loadMore;
}, [loadMore]);
useEffect(() => {
if (!sentinelRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMoreRef.current();
},
{ rootMargin: '200px' } // preload before the edge
);
observer.observe(sentinelRef.current);
return () => observer.disconnect(); // cleanup — the observer must die with the effect
}, []);
return sentinelRef;
}
List grows downward. When the sentinel div approaches the viewport bottom:
[ entry 1 ] ┐
[ entry 2 ] │ viewport
[ entry 3 ] │
─────────────┘
( sentinel ) ← rootMargin 200px makes the observer fire EARLY
→ loadMore() → next page appended → sentinel moves back out of range
Why the loadMoreRef dance? The observer effect runs once with []. If loadMore changes
(a new page closure, new filters), the observer still calls the first one — a stale
closure, exactly Lesson 5. Keeping the latest callback in a ref means the once-created
observer always fires with the current logic. This "ref for the latest callback" trick shows
up in every one of these tasks.
function Feed() {
const [items, setItems] = useState(seedItems);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useInfiniteScroll(() => {
if (!hasMore) return;
setPage((p) => {
fetch(`/api/items?page=${p + 1}`)
.then((r) => r.json())
.then((next) => {
setItems((prev) => [...prev, ...next.items]); // append, don't push
setHasMore(next.hasMore);
});
return p + 1;
});
});
return (
<div>
{items.map((item) => <div key={item.id}>{item.title}</div>)}
{hasMore && <div ref={sentinelRef} />} {/* the sentinel — this is all the observer needs */}
</div>
);
}
Render result: an endlessly-growing feed of <div>s; a new page loads as you near the bottom.
Pitfall
Two classic failures. Stale callback: the observer was created once and keeps calling
the first loadMore — fixed by the ref above. Missing keys: items.map without a
stable key means React re-renders everything instead of appending, and the scroll
position visibly jumps. Both get asked as follow-ups.
Task 3 — Modal
The prompt: "Build a modal that closes on Escape and keeps focus inside."
Two design decisions to state up front. Portal or fixed overlay? The answer is a portal
(createPortal) when the modal might be nested in a component with overflow: hidden,
transform, or a low z-index — the portal renders into document.body, so no ancestor
can clip it. A plain fixed overlay is fine when you control the ancestors; the portal is the
answer that never needs that caveat. Where does the state live? In the parent, as a
boolean. The modal is controlled: open + onClose in, nothing out.
The keyboard part has two halves. Escape closes — a keydown listener on the window. And
focus is trapped: the dialog is a focus island. When it opens, focus moves to the dialog
(start on the close button or the dialog itself); Tab cycles within it instead of escaping
to the page behind; when it closes, focus returns to the trigger. For a live-coding round a
minimal trap — hold focus inside with a Tab loop — is plenty; the full inert/focus
management is the senior follow-up.
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
function Modal({ open, onClose, title, children }) {
const dialogRef = useRef(null);
useEffect(() => {
if (!open) return;
const previousFocus = document.activeElement; // remember who opened us
dialogRef.current?.focus(); // focus moves INTO the dialog
const onKey = (e) => {
if (e.key === 'Escape') onClose(); // Escape closes — the #1 expectation
if (e.key === 'Tab') { // minimal focus trap
const focusables = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
previousFocus?.focus(); // and return focus to the trigger
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body // portal: no ancestor can clip us
);
}
Render result:
dimmed page behind ─────────────────────┐
│ ( overlay, click = close )
┌──────────────────────────┐ │
│ Delete project? │ │
│ This can't be undone. │ │ ← focus is inside, Tab cycles here
│ [Cancel] [Delete] │ │
└──────────────────────────┘ │
Escape closes · focus returns to the button that opened it
Note
tabIndex={-1} on the dialog is what makes .focus() work on a non-interactive element.
Leaving it out is the classic "focus() silently does nothing" bug.
Task 4 — Tabs
The prompt: "Build a tabs component. Click a tab, see its panel. Keyboard arrows should move between tabs."
Tabs are a controlled index: one number in state, activeIndex, and everything derives
from it. The tab buttons and the panels are both rendered from a single tabs array, which
guarantees they can never fall out of sync. The accessibility pattern is the WAI-ARIA tabs
pattern — role="tablist" / role="tab" / role="tabpanel", wired together with ids — and
the keyboard part is small: arrow keys move the index, and the roving tabindex pattern
keeps only the active tab in the tab order.
function Tabs({ tabs }) {
const [activeIndex, setActiveIndex] = useState(0);
const activate = (i) => {
const next = (i + tabs.length) % tabs.length; // wrap: 0 → 1 → 2 → 0
setActiveIndex(next);
document.getElementById(`tab-${next}`)?.focus(); // keyboard move lands focus too
};
const onKeyDown = (e) => {
if (e.key === 'ArrowRight') { e.preventDefault(); activate(activeIndex + 1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); activate(activeIndex - 1); }
};
return (
<div>
<div role="tablist" aria-label="Settings" onKeyDown={onKeyDown}>
{tabs.map((tab, i) => (
<button
key={tab.id}
id={`tab-${i}`}
role="tab"
aria-selected={i === activeIndex}
aria-controls={`panel-${i}`}
tabIndex={i === activeIndex ? 0 : -1} // only the active tab is tabbable
onClick={() => setActiveIndex(i)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, i) => (
<div
key={tab.id}
id={`panel-${i}`}
role="tabpanel"
aria-labelledby={`tab-${i}`}
hidden={i !== activeIndex}
>
{tab.content}
</div>
))}
</div>
);
}
Render result:
[ General | Security | Billing ] ← ArrowRight / ArrowLeft move the selection
┌──────────────────────────────┐
│ Change your password… │ ← panel 1 shown, others hidden
└──────────────────────────────┘
One important detail: clicking a tab does not move focus to it (clicking already focuses
the button), but arrow keys must — that's why the keyboard path calls .focus(). Screen
reader users get aria-selected, keyboard users get real focus, and the panel swaps via
hidden. That's the whole WAI-ARIA tabs pattern in eleven lines.
Task 5 — Toast
The prompt: "Build a toast notification system. Any component should be able to show 'Saved' and have it disappear automatically."
Toasts invert the earlier pattern: instead of a component calling a hook, the hook is the
API. You expose an imperative toast('Saved') that any component — deep in a tree, in a
callback, in a form submit handler — can call. Implementation: a ToastProvider holds the
list in state and renders the stack; useToast() returns a toast function that appends
and starts an auto-dismiss timer.
The two decisions interviewers probe: the id (a counter, because content alone isn't unique — two "Saved" toasts must be separate) and the timer (stored per toast so it can be cleared on manual dismiss — a toast that keeps its timer after you close it is a memory leak and a surprise).
const ToastContext = createContext(null);
let toastId = 0; // module counter = unique ids
function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const dismiss = useCallback((id) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const toast = useCallback((message, type = 'info', duration = 3000) => {
const id = ++toastId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => dismiss(id), duration); // auto-dismiss timer per toast
}, [dismiss]);
return (
<ToastContext.Provider value={{ toast }}>
{children}
<div className="toast-stack" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={`toast toast--${t.type}`}>
{t.message}
<button onClick={() => dismiss(t.id)}>×</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
return ctx.toast;
}
Render result (3 seconds after calling toast('Saved', 'success')):
┌──────────────────────┐
│ ✓ Saved [×] │ ← appears bottom-right (or top), then slides out
└──────────────────────┘
Tip
Now the "Why line" pays off: you've built five features with the same skeleton — state in a hook, side effects cleaned up, imperative escape hatch via ref or context. The toast is the tabs hook with the state moved up into a provider. Same muscles, one more layer.
Interview Explanation
The five common coding tasks are small UI features with reusable answers. Debounced search: debounce the value in a hook, fetch in an effect that depends on the debounced value. Infinite scroll:
IntersectionObserveron a sentinel element, wrapped in a hook, appended state. Modal: controlledopen, portal todocument.body, Escape to close, and focus returned on unmount. Tabs: oneactiveIndex, arrow keys wrapping it, WAI-ARIA roles, only the active tab tabbable. Toasts: context-backed store, imperativetoast()API, per-toast auto-dismiss timers. The through-line: state in a hook, effects cleaned up, refs for the non-rendering values.
Senior-Level Insights
The tasks are the same for juniors and seniors — the code differs. The senior signals:
- Names the design decision before writing it. "I'll debounce the value, not the event." "Portal, because the button lives inside an overflow container." "A counter for ids, because content isn't unique."
- Reaches for the hook shape automatically.
useDebounce,useInfiniteScroll,useTabs— the component is a demo of the hook. - Talks about the effect lifecycle. Every effect has a cleanup; the observer disconnects, the timer clears, the listener removes, focus returns.
- Knows the stale-closure trap everywhere. The infinite-scroll ref trick, the
cancelledflag in search, theloadMoreRef— all the same Lesson 5 mechanism, anticipated rather than debugged. - Adds the a11y minimum without being asked.
role="dialog",aria-selected,aria-live,tabIndex— each is one attribute, and each is noticed.
The juniors code; the seniors talk while they code. Say what you're doing and why, in one sentence per decision, and the code becomes evidence for the explanation.
Common Mistakes
- Fetching on every keystroke. The search fires a request per character — debounce the value, not the event.
- Stale response wins. A slow earlier request resolves after a fast later one. Fix with the
cancelledflag (or abort the controller). items.push()instead of append. Mutating the array is Lesson 6's reference trap: React sees the same array reference and skips the render.- Never disconnecting the observer. An unmounted feed keeps its
IntersectionObserverattached to a dead node — a leak the cleanup would have prevented. - Escape listener never removed. The keydown listener stays on
windowafter the modal closes; the next modal has two, then three, then… - Focus trap misses Shift+Tab. Trapping forward Tab only; backwards Tab walks out of the dialog.
- Clicking the overlay closes the modal… but so does clicking inside. The classic one-line bug: forgetting
e.stopPropagation()on the dialog. - Content as the toast key. Two "Saved" toasts collide, one disappears, React warns about duplicate keys.
focus()silently doing nothing. The dialog needstabIndex={-1}before.focus()works on it.
Best Practices
✅ Debounce the value, not the event — the effect dependency becomes the debounced value, which is trivially correct
✅ IntersectionObserver over scroll listeners — declarative, and the browser does the geometry
✅ Append state functionally — setItems(prev => [...prev, ...next]), never push
✅ Every effect gets a cleanup — disconnect, clear, remove; then there are no leaks to debug
✅ Portal the modal into document.body — no ancestor can clip or z-index it away
✅ Tabs keep one source of truth — activeIndex only; panels are derived from the same array
✅ Context for anything "any component" — toast state in a provider, useToast() as the API
❌ Don't trap focus to inside the modal but forget to return it on close
❌ Don't render panels by conditionals and lose the hidden/aria wiring
❌ Don't start a timer you can't cancel — a manual-dismissed toast must clear its own timer
Interview Questions
Q1. How would you build a debounced search input?
Controlled input:
valuein state, and auseDebounce(value, 300)hook that returns a delayed copy. A fetch effect depends on the debounced value, so typing alone never triggers a request — only 300ms of silence does. I also guard against out-of-order responses with a cancelled flag orAbortController.
Q2. Infinite scroll: scroll listener or IntersectionObserver?
IntersectionObserver. I attach it once to a sentinel element at the bottom of the list and get a callback when it enters the viewport, with a
rootMarginto preload. No scroll events, no position math. The hook stores the latest callback in a ref so the once-created observer never fires a stale one, and disconnects on cleanup.
Q3. What makes a modal accessible?
Escape closes it, focus moves into the dialog when it opens and returns to the trigger on close, and Tab cycles inside it — a minimal trap. The dialog carries
role="dialog",aria-modal, and a labelled title. I'd render it through a portal so no ancestor can clip it, andaria-liveregions elsewhere announce the result.
Q4. How do tabs work under the hood?
A single controlled
activeIndex. The tab buttons and panels are both mapped from the same array, so they can't desync. Arrow keys adjust the index with wraparound and move real focus; the active tab is the only one tabbable — the roving tabindex pattern — andaria-selectedmirrors the state for screen readers.
Q5. How do toasts know when to disappear?
Each toast gets its own id from a counter and its own
setTimeoutfor auto-dismiss. The provider holds the list in state and renders the stack; a dismiss function filters by id, and the manual close button and the timer share that same path, so a manual close clears the pending timer.
Senior follow-up: How are all five tasks the same problem?
They're all "state in a hook, effects cleaned up". Debounce and toasts are timers; infinite scroll and the modal are observers and listeners; tabs is pure state. Each one returns a small reusable piece —
useDebounce,useInfiniteScroll, aModal,useTabs,useToast— and the component is just a demo of that piece. The only variation is where the state lives: local for tabs, a ref for the observer, context for toasts.
Follow-up Questions
What if two search requests overlap and the slow one arrives last?
Race the responses. Either a
cancelledflag flipped in the effect cleanup, or anAbortControllerthat aborts the previous request when the next one starts. Only the response for the latest query is committed.
Why does the sentinel need a ref, and what breaks without it?
The observer needs the actual DOM node to observe. If the sentinel is unmounted (no more pages), the effect must not observe a null node, and when the node exists it must be the current one — the ref re-attaches across renders. And the observer itself must disconnect, or the unmounted node keeps being observed.
Why not just render the modal with position: fixed?
Fixed is fine until an ancestor has
transform,filter, oroverflow: hidden— those make fixed position relative to that ancestor, not the viewport, and the modal gets clipped. A portal intodocument.bodysidesteps every ancestor constraint. Mention both and you've answered the senior version.
Why aria-selected AND tabIndex on tabs — aren't they redundant?
Different audiences.
aria-selectedis for screen readers;tabIndexis for keyboard users — only the active tab should be a Tab stop, so tabbing into the tablist lands on the current tab and arrows move from there. Removing either breaks one of the two.
How would you add a "loading" state to infinite scroll?
The simplest honest version: a boolean in state toggled around the fetch, and the sentinel renders a spinner when it's true so the observer doesn't double-fire while a page is in flight. That flag also answers "why did the same page load twice?"
Comparison Table
| Aspect | Debounced search | Infinite scroll | Modal | Tabs | Toast |
|---|---|---|---|---|---|
| Core state | query + debounced copy | items, page | open boolean | activeIndex | toasts[] in provider |
| Timing | debounce timer (300ms) | observer callback | Escape keydown | immediate | auto-dismiss timer |
| Reusable piece | useDebounce | useInfiniteScroll | Modal | useTabs | useToast |
| Key edge case | stale/out-of-order response | stale callback, missing keys | focus trap & return | panel/tab desync | duplicate content ids |
| State location | local hook | local hook + ref | parent, controlled | local hook | context provider |
| A11y minimum | — | — | role="dialog", aria-modal | tab roles + aria-selected | aria-live="polite" |
Code Example
The full live-coding drill in one gulp — all five hooks, wired into a demo app. This is the "answer key" shape: primitives first, then a screen that exercises every one. Read it as the reference for what "complete" means at the end of a round.
// ===== Task 1 — debounced search =====
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// ===== Task 2 — infinite scroll =====
function useInfiniteScroll(loadMore) {
const observerRef = useRef(null);
const sentinelRef = useRef(null);
const loadMoreRef = useRef(loadMore);
useEffect(() => {
loadMoreRef.current = loadMore;
}, [loadMore]);
useEffect(() => {
if (!sentinelRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMoreRef.current();
},
{ rootMargin: '200px' }
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, []);
return sentinelRef;
}
// ===== Task 3 — modal =====
function Modal({ open, onClose, title, children }) {
const dialogRef = useRef(null);
useEffect(() => {
if (!open) return;
const previousFocus = document.activeElement;
dialogRef.current?.focus();
const onKey = (e) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key !== 'Tab') return;
const focusables = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusables.length) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
previousFocus?.focus();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="overlay" onClick={onClose}>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body
);
}
// ===== Task 4 — tabs =====
function Tabs({ tabs }) {
const [activeIndex, setActiveIndex] = useState(0);
const activate = (i) => {
const next = (i + tabs.length) % tabs.length;
setActiveIndex(next);
document.getElementById(`tab-${next}`)?.focus();
};
const onKeyDown = (e) => {
if (e.key === 'ArrowRight') {
e.preventDefault();
activate(activeIndex + 1);
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
activate(activeIndex - 1);
}
};
return (
<div>
<div role="tablist" aria-label="Settings" onKeyDown={onKeyDown}>
{tabs.map((tab, i) => (
<button
key={tab.id}
id={`tab-${i}`}
role="tab"
aria-selected={i === activeIndex}
aria-controls={`panel-${i}`}
tabIndex={i === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(i)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, i) => (
<div
key={tab.id}
id={`panel-${i}`}
role="tabpanel"
aria-labelledby={`tab-${i}`}
hidden={i !== activeIndex}
>
{tab.content}
</div>
))}
</div>
);
}
// ===== Task 5 — toasts =====
const ToastContext = createContext(null);
let toastId = 0;
function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const dismiss = useCallback((id) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const toast = useCallback((message, type = 'info', duration = 3000) => {
const id = ++toastId;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => dismiss(id), duration);
}, [dismiss]);
return (
<ToastContext.Provider value={{ toast }}>
{children}
<div className="toast-stack" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={`toast toast--${t.type}`}>
{t.message}
<button onClick={() => dismiss(t.id)}>×</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
return ctx.toast;
}
// ===== Demo screen: all five, working together =====
function App() {
const [showModal, setShowModal] = useState(false);
const toast = useToast();
return (
<ToastProvider>
<Search /> {/* 1: debounced search (Task 1) */}
<Feed /> {/* 2: infinite scroll (Task 2) */}
<Tabs {/* 4: tabs (Task 4) */}
tabs={[
{ id: 'general', label: 'General', content: 'Profile settings…' },
{ id: 'security', label: 'Security', content: 'Password & sessions…' },
{ id: 'billing', label: 'Billing', content: 'Plans & invoices…' },
]}
/>
<button onClick={() => toast('Saved!', 'success')}>Save</button> {/* 5: toast (Task 5) */}
<button onClick={() => setShowModal(true)}>Delete project</button>
<Modal open={showModal} onClose={() => setShowModal(false)} title="Delete project?">
<p>This can't be undone.</p>
</Modal> {/* 3: modal (Task 3) */}
</ToastProvider>
);
}
Render result: a search box that queries on pause, a feed that loads as you scroll,
tabs you can drive with arrows, a modal that closes on Escape, and toasts that
auto-dismiss after three seconds — every hook above working at once.
Deep dive
What the interviewer is actually scoring. It's never "did the modal close on Escape".
It's the three habits visible in these five implementations: (1) state boundaries —
you know what belongs in state, a ref, or an effect before you write it; (2) lifecycle
hygiene — every effect names its cleanup, and you can say what leaks if it's missing;
(3) abstraction level — you produce a useDebounce, not a wall of inline timers. If
you demonstrate all three once, the interviewer stops testing the feature and starts
talking to you like a colleague.
Performance Notes
- When it matters: a search over a large dataset (or a server) — every keystroke is a request; a long feed with big rows — observe, don't listen to scroll, and virtualize at thousands of rows (from Lesson 76).
- When it doesn't: a 20-row list still benefits from the structure (one observer, one effect) but needs no tuning; debouncing an in-memory filter of 50 items is premature.
- The real costs to name: network requests in search; renders on every appended page in infinite scroll (keys decide whether it's an append or a full re-render); the keydown listener in the modal lives for the modal's lifetime — fine, but it must not accumulate; each toast holds a timer — cap the stack (e.g. keep the newest 5) if toasts can flood.
- Measure, then memo. If
Searchre-renders the whole screen on each keystroke, that's the place forReact.memoon the results list — not before you've measured it.
Debugging Scenarios
"My search fires two requests per pause."
The debounce hook is fine — the fetch effect is running twice. Check the dependencies: if
resultsor the fetch function is in the[...]array, the effect re-runs whenever they change, restarting the debounce. The dependency list should be exactly[debouncedQuery](plus a stable fetch, or via the ref trick from Task 2).
"The feed loads the same page twice."
The observer fired twice before the state updated, or
loadMorewasn't guarded. Two fixes: aloadingflag in the callback, andif (!hasMore) return. Both are the interviewer's favorite follow-up for a reason.
"Modal doesn't close on Escape — but only sometimes."
The keydown listener is attached when
openflips totrueand removed on cleanup. IfonClosechanges identity every render, the effect re-subscribes constantly — and if the modal is rendered whileopenwas alreadytrue, the effect never ran. The fix is stable callbacks (useCallback) and the sameopen-gated effect shape as above.
"Focus escapes the modal on Shift+Tab."
The trap only handles forward Tab. The Shift+Tab branch — checking
document.activeElement === firstand wrapping tolast— is the missing half. Also verify the dialog itself hastabIndex={-1}or the initial.focus()is a silent no-op.
"Toasts stay forever when the page is left open."
A timer was created without a matching cleanup, or a manual dismiss doesn't clear the pending timer, so a removed toast re-fires later. Keep the timer id per toast, clear it on dismiss, and cancel on unmount. The
dismissfunction must be the same one the timer captured — henceuseCallback.
Quick Revision Notes
- Debounced search = value debounce + effect on the debounced value + cancelled-flag guard.
- Infinite scroll = sentinel element +
IntersectionObserver+ latest-callback ref + functional append. - Modal = controlled
open, portal to body, Escape to close, focus in on open and back on close. - Tabs = one
activeIndex, derived panels, arrow keys with wraparound, rovingtabIndex+aria-selected. - Toast = context store + imperative
toast()+ per-toast id counter + auto-dismiss timer. - Every effect has a cleanup; every ref holds something that must not cause a render.
- The same five patterns — with keys, focus,
aria-*, and stable callbacks — are what the interviewer is actually watching for.
Cheat Sheet
useDebounce(value, delay):
state debounced ← value; effect sets timer, clears it on change
useInfiniteScroll(cb):
sentinel ref + IntersectionObserver({ rootMargin })
→ latest cb in a ref → isIntersecting → cb()
cleanup: observer.disconnect()
Modal:
open gated effect → save prev focus → focus dialog
keydown: Escape → onClose · Tab → wrap within focusables
cleanup: remove listener → restore focus
render: createPortal → overlay + role="dialog" tabIndex={-1}
Tabs:
activeIndex only · activate(i) wraps + focuses · keys → ArrowRight/Left
button: role="tab" aria-selected tabIndex={0|-1}
panel: role="tabpanel" hidden={i !== activeIndex}
Toast:
provider holds toasts[] · toast(msg, type, ms) → push + setTimeout(dismiss)
dismiss(id) filters · aria-live="polite" · stack capped at NKey Takeaways
Recap
- All five tasks are the same skeleton: state in a hook, effects cleaned up, refs for the non-rendering values
- Debounce the value, not the event — the effect dependency becomes the debounced value
IntersectionObserveron a sentinel beats a scroll listener, and the latest callback goes in a ref- The modal is controlled
open, portal to body, Escape to close, focus in and back out - Tabs are one
activeIndex; panels derive from the same array; arrows wrap and move real focus - Toasts are context + an imperative
toast()API, with per-toast ids and timers - Stale closures, missing keys, and un-cleaned effects are the three bugs that show up across all five — name them before they bite
- Build each once, and the live-coding round becomes "recite the pattern", not "invent under pressure"
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/9Why debounce the value rather than the event — what does the fetch effect's dependency list become?
Write useDebounce from memory, including the cleanup.
Why does infinite scroll keep the latest callback in a ref? What breaks if the effect closure captures the first one?
Name the two bugs a missing observer.disconnect() produces.
What does tabIndex={-1} do for a modal dialog, and why is role="dialog" not enough on its own?
Write the minimal focus trap — both Tab directions — and explain where focus goes on close.
Tabs: why does clicking a tab not need .focus() but arrow keys do?
Why must a toast id come from a counter and not from the message content?
What does the toast's manual close button and its auto-dismiss timer share, and why does that matter?
What's Next
Lesson 102 — Frontend System Design. Same "state, then explain" instinct, scaled up: you'll design whole screens instead of features, and the habits from this lesson — naming decisions, drawing the data flow, thinking about the lifecycle — become the skeleton of a full system-design answer.