Blog · Engineering

Fixing TanStack Virtual's flushSync Error in React 19

Engineering Team
Engineering Team Core Contributors
· December 23, 2025 · 6 min read

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 PROCESSING status 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:

  1. User scrolls → loadMore() sets loadingRef = true
  2. Polling fires (3s interval) → initialItems changes → Effect 1 runs
  3. Effect 1 sets loadingRef = false (even though fetch is in flight!)
  4. Fetch returns → Effect 2 checks loadingRef.current → it’s false → data ignored

The fix: Remove loadingRef.current = false from Effect 1. Only the fetcher effect should clean up its own state.

Summary

ProblemCauseFix
flushSync errorTanStack Virtual’s default onChange uses flushSync, now forbidden in React 19Provide custom onChange with useReducer
Infinite scroll breakingShared loadingRef cleared by unrelated polling effectIsolate 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.