DNA⚛ïļ ReactReal Coding Challenges
ðŸĶ–DinosaurReactCodingAutocompleteInfinite ScrollDebounceThrottle

Real Coding Challenges

Build autocomplete, infinite scroll, implement debounce and throttle — the coding questions you'll actually face in interviews.

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 setTimeout on onBlur. Without it, clicking a suggestion triggers onBlur first (closing the list) before onClick fires. 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:

  1. When called, clear any existing timer
  2. Start a new timer
  3. If called again before the timer fires, the old timer is cleared and a new one starts
  4. 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:

  1. First call goes through immediately
  2. Subsequent calls within the time window are saved (only the latest)
  3. When the window expires, the saved call fires
  4. This guarantees the function runs at most once per limit milliseconds
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 CaseDebounceThrottle
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 — WeakMap tracks 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"]) — unchanged

Common 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:

FeatureMapPlain Object
Key typesAny value (strings, numbers, objects)Strings and Symbols only
Key orderInsertion order guaranteedNot guaranteed (mostly, but edge cases)
Sizecache.sizeObject.keys(obj).length
IterationBuilt-in .forEach, .entries()Need Object.keys() first
PerformanceOptimized for frequent add/removeOptimized for static structures
PrototypeNo inherited keysHas 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:

  1. Trigger a re-render
  2. Recreate the observer effect
  3. Potentially fire the observer callback again
  4. 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:

  1. The DOM ref isn't available during render. observerTarget.current is null until after the component mounts. useEffect runs after mount, so the ref is ready.

  2. Cleanup. When the component unmounts, we need to disconnect the observer to prevent memory leaks. useEffect's return function handles this.

  3. 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 setItems on 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.