Fossils⚛ïļ React PatternsReact Coding Challenges & Code-Based Questions
ðŸĶ–DinosaurReactCodingAutocompleteInfinite ScrollDebounceInterview

React Coding Challenges & Code-Based Questions

Build autocomplete, infinite scroll, implement utilities — plus the follow-up questions interviewers ask about YOUR code.

React Coding Challenges & Code-Based Questions

This is the part of the interview where talking stops and building starts. Interviewers want to see you think through a feature step by step — not recite a memorized solution. They'll watch how you handle edge cases, ask "what if?" questions about your own code, and test whether you understand why you wrote what you wrote.


Q26: Build an Autocomplete with Debounce, Keyboard Navigation, and API Cancel

Interview Question: "Build an autocomplete search input. It should debounce API calls, support keyboard navigation, and cancel in-flight requests when the user types again."

Don't try to build everything at once. Build it in layers — each layer adds one capability.

Layer 1: Basic Input + API Call

Start with the simplest thing that works:

function Autocomplete() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
 
  async function handleChange(value: string) {
    setQuery(value);
    if (!value) { setResults([]); return; }
 
    const data = await fetch(`/api/search?q=${value}`).then(r => r.json());
    setResults(data);
  }
 
  return (
    <div>
      <input
        value={query}
        onChange={e => handleChange(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map((item, i) => (
          <li key={i}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

Problem: This fires an API call on every keystroke. Typing "react" makes 5 API calls. Let's fix that.

Layer 2: Add Debouncing

We don't want to call the API until the user pauses typing. We'll build a useDebounce hook:

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;
}

Now use it:

function Autocomplete() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const debouncedQuery = useDebounce(query, 300);
 
  useEffect(() => {
    if (!debouncedQuery) { setResults([]); return; }
 
    fetch(`/api/search?q=${debouncedQuery}`)
      .then(r => r.json())
      .then(setResults);
  }, [debouncedQuery]);
 
  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map((item, i) => (
          <li key={i}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

Now typing "react" makes 1-2 API calls instead of 5. But there's still a bug...

Layer 3: Cancel In-Flight Requests

If the user types "react" then quickly changes to "redux", the "react" request might return after the "redux" request and overwrite the correct results with stale ones. We need to cancel the old request when a new one starts.

useEffect(() => {
  if (!debouncedQuery) { setResults([]); return; }
 
  const controller = new AbortController();
 
  fetch(`/api/search?q=${debouncedQuery}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setResults)
    .catch(err => {
      if (err.name !== 'AbortError') console.error(err);
    });
 
  return () => controller.abort();
}, [debouncedQuery]);

When debouncedQuery changes, React runs the cleanup function first — which aborts the previous request. The new effect fires a fresh request. No stale data.

Layer 4: Keyboard Navigation

Now the dropdown works with clicking. Let's make it work with the keyboard:

function Autocomplete() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [activeIndex, setActiveIndex] = useState(-1);
  const [isOpen, setIsOpen] = useState(false);
  const debouncedQuery = useDebounce(query, 300);
 
  useEffect(() => {
    if (!debouncedQuery) { setResults([]); setIsOpen(false); return; }
    const controller = new AbortController();
 
    fetch(`/api/search?q=${debouncedQuery}`, { signal: controller.signal })
      .then(r => r.json())
      .then(data => {
        setResults(data);
        setIsOpen(data.length > 0);
        setActiveIndex(-1);
      })
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      });
 
    return () => controller.abort();
  }, [debouncedQuery]);
 
  function handleKeyDown(e: React.KeyboardEvent) {
    if (!isOpen) return;
 
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(prev => Math.min(prev + 1, results.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(prev => Math.max(prev - 1, 0));
        break;
      case 'Enter':
        e.preventDefault();
        if (activeIndex >= 0) {
          setQuery(results[activeIndex]);
          setIsOpen(false);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        setActiveIndex(-1);
        break;
    }
  }
 
  return (
    <div>
      <input
        value={query}
        onChange={e => {
          setQuery(e.target.value);
          setActiveIndex(-1);
        }}
        onKeyDown={handleKeyDown}
        placeholder="Search..."
      />
      {isOpen && (
        <ul>
          {results.map((item, i) => (
            <li
              key={i}
              className={i === activeIndex ? 'bg-blue-100' : ''}
              onClick={() => { setQuery(item); setIsOpen(false); }}
              onMouseEnter={() => setActiveIndex(i)}
            >
              {item}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

The Complete Solution — All Layers Combined

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 [results, setResults] = useState<string[]>([]);
  const [activeIndex, setActiveIndex] = useState(-1);
  const [isOpen, setIsOpen] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const debouncedQuery = useDebounce(query, 300);
 
  useEffect(() => {
    if (!debouncedQuery) {
      setResults([]);
      setIsOpen(false);
      return;
    }
 
    const controller = new AbortController();
    setIsLoading(true);
 
    fetch(`/api/search?q=${debouncedQuery}`, { signal: controller.signal })
      .then(r => r.json())
      .then(data => {
        setResults(data);
        setIsOpen(data.length > 0);
        setActiveIndex(-1);
        setIsLoading(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error(err);
          setIsLoading(false);
        }
      });
 
    return () => controller.abort();
  }, [debouncedQuery]);
 
  function handleKeyDown(e: React.KeyboardEvent) {
    if (!isOpen) return;
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        setActiveIndex(prev => Math.min(prev + 1, results.length - 1));
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex(prev => Math.max(prev - 1, 0));
        break;
      case 'Enter':
        e.preventDefault();
        if (activeIndex >= 0) {
          setQuery(results[activeIndex]);
          setIsOpen(false);
        }
        break;
      case 'Escape':
        setIsOpen(false);
        setActiveIndex(-1);
        break;
    }
  }
 
  return (
    <div className="relative">
      <input
        value={query}
        onChange={e => { setQuery(e.target.value); setActiveIndex(-1); }}
        onKeyDown={handleKeyDown}
        onFocus={() => results.length > 0 && setIsOpen(true)}
        onBlur={() => setTimeout(() => setIsOpen(false), 150)}
        placeholder="Search..."
      />
      {isLoading && <span className="absolute right-2 top-2">Loading...</span>}
      {isOpen && (
        <ul className="absolute w-full border rounded shadow bg-white">
          {results.map((item, i) => (
            <li
              key={i}
              className={`p-2 cursor-pointer ${i === activeIndex ? 'bg-blue-100' : ''}`}
              onClick={() => { setQuery(item); setIsOpen(false); }}
              onMouseEnter={() => setActiveIndex(i)}
            >
              {item}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

The One-Liner That Impresses: "A production autocomplete has four layers: debounced input to reduce API calls, AbortController to cancel stale requests, keyboard navigation for accessibility, and loading/error states for UX — each layer independently testable."

Red Flags in Your Answer

  • No debouncing — making an API call on every keystroke
  • No request cancellation — race conditions cause stale results
  • No keyboard navigation — it's an accessibility requirement
  • Using setTimeout to "cancel" requests instead of AbortController
  • Not handling the onBlur edge case (dropdown should close when clicking outside)

Q27: Build Infinite Scroll — With Caching and Virtualization

Interview Question: "Build an infinite scroll list. Then explain how you'd add caching and virtualization."

Step 1: The Core Hook

function useInfiniteScroll<T extends { id: string }>(
  fetchPage: (cursor: string | null) => Promise<{ items: T[]; nextCursor: string | null }>
) {
  const [items, setItems] = useState<T[]>([]);
  const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
  const cursorRef = useRef<string | null>(null);
  const hasMoreRef = useRef(true);
  const isFetchingRef = useRef(false);
 
  const loadMore = useCallback(async () => {
    if (isFetchingRef.current || !hasMoreRef.current) return;
    isFetchingRef.current = true;
    setStatus('loading');
 
    try {
      const { items: newItems, nextCursor } = await fetchPage(cursorRef.current);
 
      setItems(prev => {
        const existingIds = new Set(prev.map(item => item.id));
        const unique = newItems.filter(item => !existingIds.has(item.id));
        return [...prev, ...unique];
      });
 
      cursorRef.current = nextCursor;
      hasMoreRef.current = nextCursor !== null;
      setStatus('idle');
    } catch {
      setStatus('error');
    } finally {
      isFetchingRef.current = false;
    }
  }, [fetchPage]);
 
  return { items, status, loadMore, hasMore: hasMoreRef.current };
}

Step 2: The IntersectionObserver Trigger

function useInView(callback: () => void) {
  const ref = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
 
    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) callback(); },
      { rootMargin: '200px' }
    );
 
    observer.observe(el);
    return () => observer.disconnect();
  }, [callback]);
 
  return ref;
}

Step 3: Putting It Together

function InfiniteList() {
  const { items, status, loadMore, hasMore } = useInfiniteScroll(fetchPosts);
  const sentinelRef = useInView(loadMore);
 
  return (
    <div>
      {items.map(item => (
        <PostCard key={item.id} post={item} />
      ))}
 
      {status === 'loading' && <Skeleton count={3} />}
 
      {status === 'error' && (
        <button onClick={loadMore}>Something went wrong. Try again.</button>
      )}
 
      {hasMore && status === 'idle' && <div ref={sentinelRef} />}
 
      {!hasMore && <p className="text-gray-500 text-center">No more posts.</p>}
    </div>
  );
}

Step 4: Adding Caching (The Follow-Up Question)

The interviewer asks: "What happens when the user navigates away and comes back?"

Without caching, the list reloads from scratch. With React Query's useInfiniteQuery:

function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts(pageParam),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    staleTime: 5 * 60 * 1000,
  });
}

Now when the user navigates back, the cached pages are shown instantly while React Query revalidates in the background.

Step 5: Adding Virtualization (The Senior Touch)

With 10,000 items, the DOM has 10,000 nodes — scroll becomes laggy. Virtualization only renders visible items:

import { useVirtualizer } from '@tanstack/react-virtual';
 
function VirtualizedList({ items }: { items: Post[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
 
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 120,
    overscan: 5,
  });
 
  return (
    <div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: `${virtualRow.start}px`,
              height: `${virtualRow.size}px`,
              width: '100%',
            }}
          >
            <PostCard post={items[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

10,000 items in state, but only ~15 DOM nodes at any time.

The One-Liner That Impresses: "Infinite scroll is a three-layer cake: cursor-based pagination prevents duplicates, IntersectionObserver replaces scroll events, and virtualization keeps the DOM size fixed regardless of data size — combined with React Query, you get caching and background revalidation for free."

Red Flags in Your Answer

  • Not preventing duplicate API calls (double-firing IntersectionObserver)
  • Not deduplicating items when merging into state
  • Using scroll events instead of IntersectionObserver
  • No error recovery mechanism (user is stuck if one request fails)
  • Not mentioning virtualization for large lists

Q28: Implement Debounce, Throttle, Deep Clone, and Flatten Array

Interview Question: "Implement these utility functions from scratch."

These are the "can you actually code?" questions. Interviewers want clean implementations with edge case handling.

Implement Debounce

function debounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
  let timer: ReturnType<typeof setTimeout>;
 
  const debounced = (...args: Parameters<T>) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
 
  debounced.cancel = () => clearTimeout(timer);
 
  return debounced;
}
 
// Usage
const debouncedSearch = debounce((query: string) => {
  fetch(`/api/search?q=${query}`);
}, 300);
 
debouncedSearch('react');
debouncedSearch('react h');
debouncedSearch('react hooks'); // Only this one fires after 300ms

Why clearTimeout before setTimeout? Each new call cancels the previous timer and starts a fresh one. The function only fires when no new calls arrive for delay milliseconds.

Implement Throttle

function throttle<T extends (...args: any[]) => void>(
  fn: T,
  interval: number
): (...args: Parameters<T>) => void {
  let lastCall = 0;
  let timer: ReturnType<typeof setTimeout> | null = null;
 
  return (...args: Parameters<T>) => {
    const now = Date.now();
    const remaining = interval - (now - lastCall);
 
    if (remaining <= 0) {
      if (timer) { clearTimeout(timer); timer = null; }
      lastCall = now;
      fn(...args);
    } else if (!timer) {
      timer = setTimeout(() => {
        lastCall = Date.now();
        timer = null;
        fn(...args);
      }, remaining);
    }
  };
}
 
// Usage
const throttledScroll = throttle(() => {
  console.log('Scroll position:', window.scrollY);
}, 100);
 
window.addEventListener('scroll', throttledScroll);

Why the trailing timer? Without it, the very last call might be dropped. The timer ensures the final invocation always fires.

Implement Deep Clone

function deepClone<T>(value: T): T {
  if (value === null || typeof value !== 'object') return value;
 
  if (value instanceof Date) return new Date(value.getTime()) as T;
  if (value instanceof RegExp) return new RegExp(value.source, value.flags) as T;
  if (value instanceof Map) {
    const map = new Map();
    value.forEach((v, k) => map.set(deepClone(k), deepClone(v)));
    return map as T;
  }
  if (value instanceof Set) {
    const set = new Set();
    value.forEach(v => set.add(deepClone(v)));
    return set as T;
  }
 
  if (Array.isArray(value)) return value.map(item => deepClone(item)) as T;
 
  const cloned = {} as Record<string, unknown>;
  for (const key of Object.keys(value)) {
    cloned[key] = deepClone((value as Record<string, unknown>)[key]);
  }
  return cloned as T;
}
 
// Usage
const original = { a: 1, b: { c: [1, 2, { d: 3 }] }, e: new Date() };
const clone = deepClone(original);
clone.b.c[2].d = 999;
console.log(original.b.c[2].d); // Still 3 — truly deep

Interview tip: Mention that structuredClone() exists natively in modern browsers and Node.js 17+. Show you know the native solution AND can implement it.

Implement Flatten Array

function flatten<T>(arr: (T | T[])[], depth = Infinity): T[] {
  const result: T[] = [];
 
  function walk(items: (T | T[])[], currentDepth: number) {
    for (const item of items) {
      if (Array.isArray(item) && currentDepth < depth) {
        walk(item, currentDepth + 1);
      } else {
        result.push(item as T);
      }
    }
  }
 
  walk(arr, 0);
  return result;
}
 
// Usage
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]]

Interview tip: Mention Array.prototype.flat(depth) exists natively. Then show you can implement it — that's the point of the exercise.

The One-Liner That Impresses: "Every utility function interview tests the same skill: can you handle recursion, edge cases, and cleanup? Debounce is timer management, throttle is time tracking, deep clone is recursive traversal with type checks, and flatten is recursive reduction with depth control."

Red Flags in Your Answer

  • Debounce without clearTimeout (doesn't cancel previous timer)
  • Throttle without a trailing edge (drops the last call)
  • Deep clone using JSON.parse(JSON.stringify()) without mentioning its limitations (loses functions, Dates, undefined, circular refs)
  • Flatten without depth support
  • Not mentioning native alternatives (structuredClone, Array.flat)

Code-Based Questions: Follow-Ups About YOUR Infinite Scroll Code

Interviewers don't just ask you to build it — they then interrogate your code. Here are the questions they ask and the answers they want:

"Why use a Map (or Set) for deduplication instead of Array.includes?"

"Set.has() and Map.get() are O(1) — constant time lookup. Array.includes() is O(n) — it scans every element. With 10,000 items, Set checks instantly while Array.includes checks all 10,000. For deduplication on every page load, this difference adds up."

"Why use useRef for isFetching instead of useState?"

"Changing a ref doesn't trigger a re-render. We only need isFetching to prevent duplicate calls — we don't need to show it in the UI. Using useState would cause an extra render every time we start and stop fetching, which is wasted work."

"Why not use state for hasMore?"

"Same reason. hasMore controls whether we should fetch — it's a guard, not a UI value. If the user is scrolling through 100 pages, that's 200 unnecessary re-renders (one for hasMore = true, one for the data) if hasMore is in state. A ref avoids all of them."

"Why is the IntersectionObserver inside useEffect?"

"The observer needs to attach to a DOM element, which only exists after render. useEffect runs after the component mounts and the DOM is ready. It also lets us return a cleanup function that calls observer.disconnect(), preventing memory leaks when the component unmounts."

"How do you avoid duplicate API calls?"

"Three defenses: (1) isFetchingRef prevents a second call while the first is in-flight. (2) Cursor-based pagination means each request asks for different data. (3) When merging results, we filter out items whose IDs already exist in state. Belt, suspenders, and a safety pin."

"What happens if the API fails?"

"We catch the error, set status to 'error', and render a retry button. The sentinel element is hidden during the error state so the IntersectionObserver doesn't trigger more failed calls. When the user clicks retry, loadMore fires again with the same cursor — retrying the exact page that failed."

"How do you cancel the API request?"

"AbortController. Create one per request, pass its signal to fetch, and call controller.abort() in the useEffect cleanup. When the component unmounts or dependencies change, the in-flight request is cancelled. Catch the AbortError and ignore it — it's expected, not an error."

useEffect(() => {
  const controller = new AbortController();
 
  fetchPage(cursor, controller.signal)
    .then(handleSuccess)
    .catch(err => {
      if (err.name !== 'AbortError') handleError(err);
    });
 
  return () => controller.abort();
}, [cursor]);

"How would you add scroll position restoration?"

"Save window.scrollY to a ref or sessionStorage before navigation. On return, wait for items to render (useLayoutEffect or requestAnimationFrame), then scroll to the saved position. With virtualization, you need to restore the virtualizer's scroll offset, not just window scroll."


Preparation Strategy: Don't Memorize — Understand Deeply

Here's the secret that most interview prep advice gets wrong: you don't need to memorize 30 questions. You need to deeply understand ONE feature and be able to explain every decision in it.

The One Feature Strategy

Pick one feature you've built — an autocomplete, an infinite scroll, a form with validation — and prepare to answer these about it:

Question TypeExample
Performance"Why did you debounce? What delay? Why not throttle?"
Edge cases"What if the API fails? What about empty results? Duplicate data?"
Trade-offs"Why useRef instead of useState? Why IntersectionObserver instead of scroll events?"
Scaling"What happens with 100,000 items? How would you add virtualization?"
Accessibility"Can a keyboard user navigate the results? Screen reader support?"

The Explanation Pattern

For any code you write, practice explaining it in this order:

  1. What — "This is an infinite scroll with cursor-based pagination"
  2. Why this approach — "Cursor-based prevents duplicates, unlike offset-based"
  3. Edge cases handled — "Duplicate prevention, error retry, loading states"
  4. What I'd add in production — "React Query for caching, virtualization for performance, a11y for the sentinel"
  5. Trade-offs I made — "Used refs over state for flags to avoid unnecessary renders"

What Interviewers Actually Evaluate

They DON'T care aboutThey DO care about
Memorized solutionsYour reasoning process
Perfect syntaxHandling edge cases
Knowing every APIExplaining trade-offs
Speed of typingAsking clarifying questions
One "right" answerAwareness of alternatives

The Mindset

When an interviewer asks "build X," they're not testing if you can build X. They're testing:

  • Can you break a problem into steps? (Don't build everything at once)
  • Do you handle the unhappy path? (Errors, loading, empty states)
  • Do you know why you made each choice? (Ref vs state, observer vs scroll event)
  • Can you discuss scaling? (What changes at 10x, 100x, 1000x items)
  • Do you think about users? (Accessibility, loading feedback, error recovery)

"The best interview answer isn't the one with the most features — it's the one where you can explain every line, defend every decision, and describe what you'd change if requirements grew."

Build fewer things. Understand them completely. That's how you pass senior React interviews.