80 lines
2.9 KiB
Markdown
80 lines
2.9 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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.
|