How we debugged the "flushSync was called from inside a lifecycle method" error when using @tanstack/react-virtual with React 19.
If you’ve upgraded to React 19 and suddenly see “flushSync was called from inside a lifecycle method” flooding your console, you’re not alone. This is how we fixed it in our production application.
The Error
After upgrading to React 19, our virtualized list started throwing this error on every scroll:
flushSync was called from inside a lifecycle method. React cannot
flush when React is already rendering. Consider moving this call
to a scheduler task or micro task.
The stack trace pointed directly at @tanstack/react-virtual:
onChange @ @tanstack_react-virtual.js:945
Virtualizer.notify @ @tanstack_react-virtual.js:349
Virtualizer.getMeasurements @ @tanstack_react-virtual.js:501
Why This Happens
TanStack Virtual uses ReactDOM.flushSync() internally to force synchronous re-renders when measurements change.
This was always a bit aggressive, but React 18 merely warned about it.
React 19 made it a hard error. The React team now strictly forbids flushSync during the render phase
because it can cause priority inversion and other scheduling bugs.
Here’s the problematic pattern:
// ❌ This triggers the error in React 19
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
// No onChange = library uses flushSync internally
});
// During render, this calls getVirtualItems() which may recalculate
// measurements, which calls onChange(), which calls flushSync()
const virtualItems = rowVirtualizer.getVirtualItems();
The Fix
The solution is to provide your own onChange handler that uses a standard React state update instead of flushSync:
import { useReducer, useCallback } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedList({ items }) {
const parentRef = useRef(null);
// Simple counter to force re-renders
const [, rerender] = useReducer((x) => x + 1, 0);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
overscan: 5,
// ✅ Override default flushSync behavior
onChange: useCallback(() => rerender(), []),
});
const virtualItems = rowVirtualizer.getVirtualItems();
// ... rest of component
}
Why useReducer Instead of useState?
You might wonder why we use useReducer((x) => x + 1, 0) instead of useState.
The answer is identity stability. With useState, you’d need to write:
const [, setTick] = useState(0);
// onChange: () => setTick(t => t + 1) // Creates new function each render
With useReducer, the dispatch function (our rerender) is stable across renders,
which means our useCallback(() => rerender(), []) truly has no dependencies and never recreates.
Bonus: A Race Condition We Found Along the Way
While debugging this, we discovered a second bug: our infinite scroll was breaking intermittently.
The Setup:
- We have a list of fiscal documents with pagination
- Documents in
PROCESSINGstatus trigger a 3-second polling loop to check for updates - Users can scroll to load more pages
The Bug:
// Effect 1: Sync from server data (runs on every poll)
useEffect(() => {
if (filtersChanged) {
setItems(initialItems);
}
loadingRef.current = false; // 💥 Problem!
}, [initialItems]);
// Effect 2: Handle infinite scroll results
useEffect(() => {
if (fetcher.state === 'idle' && fetcher.data && loadingRef.current) {
// Append new items...
loadingRef.current = false;
}
}, [fetcher.state, fetcher.data]);
What happened:
- User scrolls →
loadMore()setsloadingRef = true - Polling fires (3s interval) →
initialItemschanges → Effect 1 runs - Effect 1 sets
loadingRef = false(even though fetch is in flight!) - Fetch returns → Effect 2 checks
loadingRef.current→ it’sfalse→ data ignored
The fix: Remove loadingRef.current = false from Effect 1. Only the fetcher effect should clean up its own state.
Summary
| Problem | Cause | Fix |
|---|---|---|
flushSync error | TanStack Virtual’s default onChange uses flushSync, now forbidden in React 19 | Provide custom onChange with useReducer |
| Infinite scroll breaking | Shared loadingRef cleared by unrelated polling effect | Isolate state management per-effect |
Upgrading to React 19? Search your codebase for useVirtualizer and add the onChange override. Future you will thank present you.
Building complex financial integrations? Check out the TaxBridge Documentation to see how we handle the hard stuff for you.