Files
2026-07-12 10:17:17 -04:00

2.9 KiB

React Infinite Re-Render Loops from Unstable Hook Callbacks

Symptom

A component re-fetches data in a continuous loop — data loads, then immediately loads again. The component tree doesn't crash but the page is unusable due to constant loading spinners or flickering content.

Root Cause

A custom hook stores a caller-provided callback function in a useCallback/useMemo/useEffect dependency array. The caller passes an inline arrow function like (page, perPage) => fetchData(userId, page, perPage) that becomes a new reference on every render. This creates:

render → new fetchPage → load callback changes → useEffect fires → setState → re-render → new fetchPage → ...

Self-Diagnosis Checklist

  • The component uses a custom hook (usePagedList, useAsync, useQuery, etc.)
  • The hook receives a callback function as an argument
  • The callback is defined inline: (args) => someFunction(data, args)
  • Inside the hook, the callback appears in a useCallback or useEffect dependency array
  • API calls fire continuously with no user interaction

Fix Patterns

Pattern A — Ref inside the hook (robust)

Modify the custom hook to store the callback in a useRef. This breaks the dependency chain:

export function usePagedList<T>(
  fetchPage: (page: number, perPage: number) => Promise<PagedResult<T>>,
  perPage = 50,
) {
  // Store in ref so the hook's internal callbacks don't depend on fetchPage
  const fetchRef = useRef(fetchPage);
  fetchRef.current = fetchPage;

  const load = useCallback(async (p: number, append: boolean) => {
    // Use fetchRef.current instead of fetchPage
    const result = await fetchRef.current(p, perPage);
    // ...
  }, [perPage]);  // fetchPage NOT in deps — stable

  useEffect(() => { load(1, false); }, [load]);  // fires only once
  // ...
}

Pattern B — Memoize at the call site (specific)

For third-party hooks you can't modify:

const fetchAssignments = useCallback(
  (page: number, perPage: number) => fetchData(userId, page, perPage),
  [userId],  // stable reference as long as userId doesn't change
);
const { items } = usePagedList(fetchAssignments, 50);

Pattern A is preferred because it fixes all callers automatically and prevents future regressions.

Real-World Example

In the SPQ project, TechnicianJobs.tsx used usePagedList with:

const { items } = usePagedList(
  (page, perPage) => {
    if (!effectiveUserId) return Promise.resolve(emptyResult);
    return fetchTechnicianAssignments(effectiveUserId, page, perPage);
  },
  50,
);

Every render created a new arrow function → load callback changed → useEffect re-fired → API call → setState → re-render → loop.

Fix applied: usePagedList now stores fetchPage in a useRef (Pattern A). This prevents the same bug for any future caller.