Real Coding Challenges
These are the coding questions you'll actually face in interviews. Not toy problems â real features that test whether you can build production-quality UI. We'll build each one step by step, starting simple and adding complexity.
Build an Autocomplete
The problem: Build a search input that shows suggestions as the user types. It must debounce API calls, support keyboard navigation, and cancel stale requests.
Think of it like this: You're building Google's search bar. The user types, suggestions appear, they can arrow-key through them and press Enter to select. Simple in concept, surprisingly complex in execution.
Step 1: Basic Input with Suggestions
Start with the simplest version â just show results:
function Autocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
if (value.length < 2) {
setSuggestions([]);
return;
}
const response = await fetch(`/api/search?q=${value}`);
const data = await response.json();
setSuggestions(data);
};
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search..." />
{suggestions.length > 0 && (
<ul>
{suggestions.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</div>
);
}This works but has three problems: it fires an API call on every keystroke, stale results can overwrite newer ones, and there's no keyboard navigation.
Step 2: Add Debounce
Debouncing means "wait until the user stops typing before doing anything." Think of it like an elevator â it waits a few seconds after the last person enters before closing the doors.
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function Autocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery.length < 2) {
setSuggestions([]);
return;
}
fetch(`/api/search?q=${debouncedQuery}`)
.then((res) => res.json())
.then(setSuggestions);
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{suggestions.length > 0 && (
<ul>
{suggestions.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</div>
);
}Now API calls only fire 300ms after the user stops typing. If they type "react hooks", you fire one call instead of eleven.
Step 3: Cancel Stale Requests
What if the user types "rea", waits (API call fires), then types "react"? The "rea" response might arrive after the "react" response, overwriting the correct results with stale ones.
function Autocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
if (debouncedQuery.length < 2) {
setSuggestions([]);
return;
}
controllerRef.current?.abort();
controllerRef.current = new AbortController();
setIsLoading(true);
fetch(`/api/search?q=${debouncedQuery}`, {
signal: controllerRef.current.signal,
})
.then((res) => res.json())
.then((data) => {
setSuggestions(data);
setIsLoading(false);
})
.catch((err) => {
if (err.name !== "AbortError") {
console.error(err);
setIsLoading(false);
}
});
return () => controllerRef.current?.abort();
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{isLoading && <p>Loading...</p>}
{suggestions.length > 0 && (
<ul>
{suggestions.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</div>
);
}AbortController cancels the previous API call when a new one fires. The old response is thrown away before it can cause problems.
Step 4: Keyboard Navigation (Complete Solution)
function Autocomplete() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const debouncedQuery = useDebounce(query, 300);
const controllerRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (debouncedQuery.length < 2) {
setSuggestions([]);
setIsOpen(false);
return;
}
controllerRef.current?.abort();
controllerRef.current = new AbortController();
setIsLoading(true);
fetch(`/api/search?q=${debouncedQuery}`, {
signal: controllerRef.current.signal,
})
.then((res) => res.json())
.then((data) => {
setSuggestions(data);
setIsOpen(data.length > 0);
setIsLoading(false);
})
.catch((err) => {
if (err.name !== "AbortError") {
console.error(err);
setIsLoading(false);
}
});
return () => controllerRef.current?.abort();
}, [debouncedQuery]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((prev) =>
prev < suggestions.length - 1 ? prev + 1 : prev
);
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0) {
selectSuggestion(suggestions[activeIndex]);
}
break;
case "Escape":
setIsOpen(false);
setActiveIndex(-1);
break;
}
};
const selectSuggestion = (value: string) => {
setQuery(value);
setIsOpen(false);
setActiveIndex(-1);
inputRef.current?.focus();
};
return (
<div>
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
onFocus={() => suggestions.length > 0 && setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 150)}
placeholder="Search..."
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-activedescendant={
activeIndex >= 0 ? `suggestion-${activeIndex}` : undefined
}
/>
{isLoading && <p>Loading...</p>}
{isOpen && suggestions.length > 0 && (
<ul role="listbox">
{suggestions.map((item, index) => (
<li
key={item}
id={`suggestion-${index}`}
role="option"
aria-selected={index === activeIndex}
onClick={() => selectSuggestion(item)}
style={{
background: index === activeIndex ? "#e0e7ff" : "white",
}}
>
{item}
</li>
))}
</ul>
)}
</div>
);
}What we built: Debounced API calls, stale request cancellation, full keyboard navigation (arrow keys, Enter, Escape), ARIA attributes for screen readers, and click selection. This is interview-ready.
Common Mistake: Forgetting the
setTimeoutononBlur. Without it, clicking a suggestion triggersonBlurfirst (closing the list) beforeonClickfires. The 150ms delay lets the click register before the list disappears.
Build Infinite Scroll
The problem: Build a list that loads more items as the user scrolls to the bottom. It must prevent duplicate API calls, handle errors, and be efficient with large datasets.
Think of it like this: Twitter's feed. You scroll down, more tweets appear. You never click "next page." It feels like the content is endless â but behind the scenes, the app is carefully loading chunks of data at exactly the right time.
Step 1: Basic Infinite Scroll
function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef<HTMLDivElement>(null);
const fetchItems = useCallback(async (pageNum: number) => {
setLoading(true);
const response = await fetch(`/api/items?page=${pageNum}&limit=20`);
const data = await response.json();
setItems((prev) => [...prev, ...data.items]);
setHasMore(data.hasMore);
setLoading(false);
}, []);
useEffect(() => {
fetchItems(1);
}, [fetchItems]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
setPage((prev) => prev + 1);
}
},
{ threshold: 1.0 }
);
const target = observerTarget.current;
if (target) observer.observe(target);
return () => {
if (target) observer.unobserve(target);
};
}, [hasMore, loading]);
useEffect(() => {
if (page > 1) {
fetchItems(page);
}
}, [page, fetchItems]);
return (
<div>
{items.map((item) => (
<div key={item.id}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
))}
<div ref={observerTarget} style={{ height: "20px" }} />
{loading && <p>Loading more items...</p>}
{!hasMore && <p>You've reached the end!</p>}
</div>
);
}The IntersectionObserver watches a small invisible div at the bottom of the list. When the user scrolls it into view, we load the next page.
Step 2: Prevent Duplicate API Calls
The basic version has a problem: rapid scrolling or fast state changes can fire multiple requests for the same page.
function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const hasMoreRef = useRef(true);
const loadingRef = useRef(false);
const observerTarget = useRef<HTMLDivElement>(null);
const fetchedPages = useRef(new Set<number>());
const fetchItems = useCallback(async (pageNum: number) => {
if (fetchedPages.current.has(pageNum)) return;
if (loadingRef.current) return;
fetchedPages.current.add(pageNum);
loadingRef.current = true;
setLoading(true);
try {
const response = await fetch(`/api/items?page=${pageNum}&limit=20`);
const data = await response.json();
setItems((prev) => [...prev, ...data.items]);
hasMoreRef.current = data.hasMore;
} catch (error) {
fetchedPages.current.delete(pageNum);
console.error("Failed to fetch page:", pageNum, error);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, []);
useEffect(() => {
fetchItems(1);
}, [fetchItems]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (
entries[0].isIntersecting &&
hasMoreRef.current &&
!loadingRef.current
) {
setPage((prev) => prev + 1);
}
},
{ threshold: 1.0 }
);
const target = observerTarget.current;
if (target) observer.observe(target);
return () => {
if (target) observer.unobserve(target);
};
}, []);
useEffect(() => {
if (page > 1) fetchItems(page);
}, [page, fetchItems]);
return (
<div>
{items.map((item) => (
<div key={item.id}>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
))}
<div ref={observerTarget} style={{ height: "20px" }} />
{loading && <p>Loading more items...</p>}
{!hasMoreRef.current && <p>You've reached the end!</p>}
</div>
);
}Key changes: fetchedPages (a Set) tracks which pages we've already requested, and loadingRef prevents concurrent fetches.
Step 3: Add Caching
If the user navigates away and comes back, we don't want to refetch everything:
const cache = new Map<string, { items: Item[]; hasMore: boolean }>();
function useCachedInfiniteScroll(endpoint: string) {
const [items, setItems] = useState<Item[]>(() => {
const cached = cache.get(endpoint);
return cached ? cached.items : [];
});
const [page, setPage] = useState(() => {
const cached = cache.get(endpoint);
return cached ? Math.ceil(cached.items.length / 20) + 1 : 1;
});
const [loading, setLoading] = useState(false);
const hasMoreRef = useRef(true);
const loadingRef = useRef(false);
const fetchedPages = useRef(new Set<number>());
const fetchPage = useCallback(
async (pageNum: number) => {
if (fetchedPages.current.has(pageNum) || loadingRef.current) return;
fetchedPages.current.add(pageNum);
loadingRef.current = true;
setLoading(true);
try {
const response = await fetch(
`${endpoint}?page=${pageNum}&limit=20`
);
const data = await response.json();
setItems((prev) => {
const updated = [...prev, ...data.items];
cache.set(endpoint, { items: updated, hasMore: data.hasMore });
return updated;
});
hasMoreRef.current = data.hasMore;
} catch (error) {
fetchedPages.current.delete(pageNum);
console.error(error);
} finally {
loadingRef.current = false;
setLoading(false);
}
},
[endpoint]
);
const loadMore = useCallback(() => {
if (hasMoreRef.current && !loadingRef.current) {
setPage((prev) => {
const next = prev + 1;
fetchPage(next);
return next;
});
}
}, [fetchPage]);
useEffect(() => {
if (!cache.has(endpoint)) fetchPage(1);
}, [endpoint, fetchPage]);
return { items, loading, hasMore: hasMoreRef.current, loadMore };
}The Map cache stores fetched data keyed by endpoint. When the component remounts, it reads from cache instead of refetching.
Step 4: Add Virtualization
With thousands of items, rendering them all tanks performance. Virtualization means only rendering what's visible on screen.
Think of it like this: A library has 100,000 books, but you can only see 20 through the window. The library doesn't display all 100,000 â it only puts 20 on the shelf you can see, and swaps them as you walk.
function VirtualizedList({
items,
itemHeight,
containerHeight,
renderItem,
}: {
items: Item[];
itemHeight: number;
containerHeight: number;
renderItem: (item: Item, index: number) => React.ReactNode;
}) {
const [scrollTop, setScrollTop] = useState(0);
const totalHeight = items.length * itemHeight;
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const overscan = 5;
const visibleStart = Math.max(0, startIndex - overscan);
const visibleEnd = Math.min(
items.length,
startIndex + visibleCount + overscan
);
const visibleItems = items.slice(visibleStart, visibleEnd);
return (
<div
style={{ height: containerHeight, overflow: "auto" }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: "relative" }}>
{visibleItems.map((item, index) => (
<div
key={item.id}
style={{
position: "absolute",
top: (visibleStart + index) * itemHeight,
height: itemHeight,
width: "100%",
}}
>
{renderItem(item, visibleStart + index)}
</div>
))}
</div>
</div>
);
}Instead of rendering 10,000 DOM nodes, we render ~30 and reposition them as the user scrolls. The outer div has the full height (so the scrollbar looks correct), but only visible items are in the DOM.
Implement Debounce
The problem: Write a debounce function from scratch.
Think of it like this: You're in an elevator. Every time someone presses the "open door" button, the door-close timer resets. The doors only close after nobody has pressed the button for 3 seconds. That's debounce â execute the function only after a pause.
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return function (...args: Parameters<T>) {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delay);
};
}How it works:
- When called, clear any existing timer
- Start a new timer
- If called again before the timer fires, the old timer is cleared and a new one starts
- The function only executes when the timer finally completes (no calls during the delay)
const debouncedSearch = debounce((query: string) => {
console.log("Searching for:", query);
}, 300);
debouncedSearch("r"); // Timer starts (300ms)
debouncedSearch("re"); // Timer resets (300ms)
debouncedSearch("rea"); // Timer resets (300ms)
debouncedSearch("reac"); // Timer resets (300ms)
debouncedSearch("react"); // Timer starts (300ms)...
// 300ms later: "Searching for: react" â only ONE call!Debounce with Cancel and Immediate
A more complete version:
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number,
options: { leading?: boolean } = {}
) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let lastArgs: Parameters<T> | null = null;
const debounced = function (...args: Parameters<T>) {
lastArgs = args;
if (options.leading && !timeoutId) {
fn(...args);
}
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (!options.leading && lastArgs) {
fn(...lastArgs);
}
timeoutId = null;
lastArgs = null;
}, delay);
};
debounced.cancel = () => {
if (timeoutId) clearTimeout(timeoutId);
timeoutId = null;
lastArgs = null;
};
return debounced;
}The leading option fires the function immediately on the first call, then ignores subsequent calls during the delay. Useful for preventing double-click submissions.
Implement Throttle
The problem: Write a throttle function from scratch.
Think of it like this: Debounce is "wait until they stop." Throttle is "only allow once every X milliseconds." Think of a bouncer at a club â one person in every 5 seconds, no matter how long the line is.
function throttle<T extends (...args: any[]) => any>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
let lastArgs: Parameters<T> | null = null;
return function (...args: Parameters<T>) {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
if (lastArgs) {
fn(...lastArgs);
lastArgs = null;
}
}, limit);
} else {
lastArgs = args;
}
};
}How it works:
- First call goes through immediately
- Subsequent calls within the time window are saved (only the latest)
- When the window expires, the saved call fires
- This guarantees the function runs at most once per
limitmilliseconds
const throttledScroll = throttle((position: number) => {
console.log("Scroll position:", position);
}, 200);
// User scrolls continuously for 1 second
// Without throttle: 60+ calls (every frame)
// With throttle: ~5 calls (every 200ms)When to Use Debounce vs Throttle
| Use Case | Debounce | Throttle |
|---|---|---|
| Search input | â Wait until typing stops | â Would show partial results |
| Window resize | â Only need final size | â Smooth resize feedback |
| Scroll position | â Would miss most events | â Regular updates |
| Button clicks | â Prevent double-submit | â Rate-limit clicks |
| API rate limiting | â Could delay too long | â Guaranteed max frequency |
Implement Deep Clone
The problem: Write a function that creates a true deep copy of an object, handling nested objects, arrays, dates, and circular references.
Think of it like this: A shallow copy is like photocopying the first page of a book â the pages inside still point to the original. A deep clone is like rewriting the entire book by hand â everything is a fresh copy.
function deepClone<T>(obj: T, seen = new WeakMap()): T {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (seen.has(obj as object)) {
return seen.get(obj as object);
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as unknown as T;
}
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags) as unknown as T;
}
if (obj instanceof Map) {
const mapCopy = new Map();
seen.set(obj as object, mapCopy);
obj.forEach((value, key) => {
mapCopy.set(deepClone(key, seen), deepClone(value, seen));
});
return mapCopy as unknown as T;
}
if (obj instanceof Set) {
const setCopy = new Set();
seen.set(obj as object, setCopy);
obj.forEach((value) => {
setCopy.add(deepClone(value, seen));
});
return setCopy as unknown as T;
}
if (Array.isArray(obj)) {
const arrCopy: any[] = [];
seen.set(obj as object, arrCopy);
obj.forEach((item, index) => {
arrCopy[index] = deepClone(item, seen);
});
return arrCopy as unknown as T;
}
const clone = Object.create(Object.getPrototypeOf(obj));
seen.set(obj as object, clone);
for (const key of Object.keys(obj)) {
clone[key] = deepClone((obj as any)[key], seen);
}
return clone;
}Key details:
- Primitives (numbers, strings, booleans, null, undefined) â returned as-is (they're already copies)
- Circular references â
WeakMaptracks objects we've already cloned to prevent infinite loops - Special types â Date, RegExp, Map, Set each need custom handling
- Arrays â handled separately from plain objects
const original = {
name: "Alice",
scores: [100, 95, 88],
metadata: { created: new Date(), tags: new Set(["admin"]) },
};
const cloned = deepClone(original);
cloned.scores.push(76);
cloned.metadata.tags.add("editor");
console.log(original.scores); // [100, 95, 88] â unchanged
console.log(original.metadata.tags); // Set(["admin"]) â unchangedCommon Mistake: Using
JSON.parse(JSON.stringify(obj))as a deep clone. It fails silently on Dates (turns them into strings), undefined values (deletes them), functions (deletes them), Maps, Sets, RegExps, and circular references (throws an error).
Implement Flatten Array
The problem: Write a function that flattens a nested array to any depth.
Think of it like this: You have a box of boxes of boxes, each containing some items. Flatten means dumping everything out onto one table â no more nesting.
function flatten(arr: any[], depth: number = Infinity): any[] {
const result: any[] = [];
for (const item of arr) {
if (Array.isArray(item) && depth > 0) {
result.push(...flatten(item, depth - 1));
} else {
result.push(item);
}
}
return result;
}flatten([1, [2, [3, [4]]]]]); // [1, 2, 3, 4]
flatten([1, [2, [3, [4]]]], 1); // [1, 2, [3, [4]]]
flatten([1, [2, [3, [4]]]], 2); // [1, 2, 3, [4]]Iterative Version (No Recursion)
Interviewers sometimes ask for a non-recursive version:
function flattenIterative(arr: any[]): any[] {
const stack = [...arr];
const result: any[] = [];
while (stack.length > 0) {
const item = stack.pop();
if (Array.isArray(item)) {
stack.push(...item);
} else {
result.push(item);
}
}
return result.reverse();
}We use a stack instead of recursion. Items are popped off; if they're arrays, their contents are pushed back onto the stack. Non-arrays go into the result. Since pop processes from the end, we reverse at the finish.
Code-Based Questions from Infinite Scroll
These are the follow-up questions an interviewer asks after you build infinite scroll. They test whether you truly understand your own code.
"Why use Map for the cache?"
const cache = new Map<string, CacheEntry>();Map vs plain object:
| Feature | Map | Plain Object |
|---|---|---|
| Key types | Any value (strings, numbers, objects) | Strings and Symbols only |
| Key order | Insertion order guaranteed | Not guaranteed (mostly, but edge cases) |
| Size | cache.size | Object.keys(obj).length |
| Iteration | Built-in .forEach, .entries() | Need Object.keys() first |
| Performance | Optimized for frequent add/remove | Optimized for static structures |
| Prototype | No inherited keys | Has inherited keys from prototype |
For a cache, Map is the better choice because we frequently add/delete entries, need reliable size, and want clean iteration.
"Why use useRef instead of state for loading and hasMore?"
const loadingRef = useRef(false);
const hasMoreRef = useRef(true);Refs don't trigger re-renders. The IntersectionObserver callback checks loadingRef.current and hasMoreRef.current to decide whether to fetch. If these were state variables, every change would:
- Trigger a re-render
- Recreate the observer effect
- Potentially fire the observer callback again
- Create a render cascade
Using refs, we can update these values silently and check them in the observer callback without any re-render overhead.
// â Using state â re-renders on every loading change, observer recreated
const [loading, setLoading] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !loading) { // Stale closure!
loadMore();
}
});
// ...
}, [loading]); // Recreated every time loading changes
// â
Using ref â no re-renders, always current value
const loadingRef = useRef(false);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !loadingRef.current) { // Always fresh
loadMore();
}
});
// ...
}, []); // Created once, ref always has current value"Why not use state for hasMore?"
Same reason as above, plus: hasMore is only checked inside the IntersectionObserver callback. It never needs to trigger a re-render on its own. The UI shows "end of list" based on whether new items arrived, not on a boolean flag. Using state for something that only needs to be read (never displayed directly) wastes renders.
"Why put the observer in useEffect?"
useEffect(() => {
const observer = new IntersectionObserver(callback);
if (target) observer.observe(target);
return () => observer.disconnect();
}, []);Three reasons:
-
The DOM ref isn't available during render.
observerTarget.currentis null until after the component mounts.useEffectruns after mount, so the ref is ready. -
Cleanup. When the component unmounts, we need to disconnect the observer to prevent memory leaks.
useEffect's return function handles this. -
Side effect isolation. Creating an IntersectionObserver is a side effect (it interacts with the browser API). React's rule: side effects go in
useEffect, not in the render function.
"How to avoid duplicate API calls?"
Three layers of protection:
const fetchedPages = useRef(new Set<number>());
const loadingRef = useRef(false);
const fetchItems = async (pageNum: number) => {
// Layer 1: Skip if we already fetched this page
if (fetchedPages.current.has(pageNum)) return;
// Layer 2: Skip if another fetch is in progress
if (loadingRef.current) return;
// Layer 3: Mark this page as fetched BEFORE the async call
fetchedPages.current.add(pageNum);
loadingRef.current = true;
try {
const data = await fetch(`/api/items?page=${pageNum}`);
// ... handle response
} catch {
// If fetch fails, remove from Set so we can retry
fetchedPages.current.delete(pageNum);
} finally {
loadingRef.current = false;
}
};The Set prevents fetching the same page twice. The loading ref prevents concurrent fetches. Marking the page before the await prevents race conditions where two calls start for the same page.
"What happens if the API fails?"
const fetchItems = async (pageNum: number) => {
fetchedPages.current.add(pageNum);
loadingRef.current = true;
try {
const response = await fetch(`/api/items?page=${pageNum}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setItems((prev) => [...prev, ...data.items]);
hasMoreRef.current = data.hasMore;
} catch (error) {
fetchedPages.current.delete(pageNum);
setError(`Failed to load page ${pageNum}. Scroll down to retry.`);
} finally {
loadingRef.current = false;
setLoading(false);
}
};Key: we delete the page from fetchedPages on failure. This allows the user to retry by scrolling again. If we didn't delete it, the page would be permanently marked as "fetched" even though it failed, and the user would be stuck.
"How to cancel the API call?"
function useInfiniteScroll(endpoint: string) {
const controllerRef = useRef<AbortController | null>(null);
const fetchItems = async (pageNum: number) => {
controllerRef.current?.abort();
controllerRef.current = new AbortController();
try {
const response = await fetch(
`${endpoint}?page=${pageNum}`,
{ signal: controllerRef.current.signal }
);
const data = await response.json();
setItems((prev) => [...prev, ...data.items]);
} catch (err) {
if (err.name !== "AbortError") {
console.error("Fetch failed:", err);
}
}
};
useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
return { items, loading, loadMore };
}AbortController cancels in-flight requests. The cleanup in useEffect ensures we abort when the component unmounts. We check err.name !== "AbortError" because aborted fetches throw an error â but it's an intentional one, not a real failure.
Common Mistake: Not aborting API calls on unmount. If the component unmounts while a fetch is in progress and the response arrives, React will try to call
setItemson an unmounted component. In older React versions this caused a warning; in newer versions it's silently ignored, but the network request still wastes bandwidth.