You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Virtualized list re-renders on every scroll frame #1062
I expect the list won't re-render with every scroll tick and will only re-render when virtual items are added or removed.
How often does this bug happen?
Every time
Screenshots or Videos
Screen.Recording.2025-10-03.at.17.19.00.mov
Platform
OS: macOS
Browser: Chrome
tanstack-virtual version
v3.13.12
TypeScript version
v5.7.3
Additional context
No response
Terms & Code of Conduct
I agree to follow this project's Code of Conduct
I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.
@neoantox what’s happening is that the item height is relatively small, so while scrolling, items are frequently being added to and removed from the viewport. Each of those changes triggers a re-render, which is expected behavior for a virtualized list.
Additionally, re-renders also occur when scrolling starts and stops because the isScrolling flag is toggled during those transitions.
@mogelbrod not really, how did you notice this behavior? Did you observe a change in render counts compared to a previous version, or notice a regression after an update?
@mogelbrod not really, how did you notice this behavior? Did you observe a change in render counts compared to a previous version, or notice a regression after an update?
I just happened to be debugging another issue and noticed that the component consuming useVirtualizer() gets re-rendered constantly while scrolling, no matter how slowly or short distance. There's probably 1-2 years since I last actively worked with react-virtualizer, but IIRC back then it didn't trigger this many renders.
I cloned @neoantox repro and modified it to instead use useVirtualizer()here. Simply scrolling 1-3 pixels down triggers multiple re-renders of the visible rows, which is not what I expected.
Looking at the docs, having useVirtualizer() return scrollOffset seems like a possible culprit. If that value is tracked using regular react state then that would seem like the reason why the hook triggers re-renders this often. #352 from 2022 appears to raise the same issue. I'd assume this value would be something consumers of the hook could opt-in to as I'm guessing most won't be using it.
I cloned @neoantox repro and modified it to instead use useVirtualizer() here. Simply scrolling 1-3 pixels down triggers multiple re-renders of the visible rows, which is not what I expected.
Note that even when the visible range doesn’t change, useVirtualizer() will still trigger re-renders due to isScrolling changing state (typically one render when scrolling starts and another when it stops). That can happen even with very small scroll deltas like 1–3px.
Looking at the docs, having useVirtualizer() return scrollOffset seems like a possible culprit.
We don’t trigger re-renders on scrollOffset changes themselves, otherwise you’d see many more re-renders while scrolling. You can see this clearly in the clip I shared.
Is there a way to opt-out of the isScrolling updates? I'm guessing a large number of use cases ignore that state entirely, and it would be nice to avoid rerendering unnecessarily.
As a reference, a backwards incompatible way to cover all cases could've been an optional onScrollUpdate callback that got called with the isScrolling and offset values, allowing the consumer to decide when to trigger a rerender by updating state.
Is there a way to opt-out of the isScrolling updates?
Currently, no, there isn’t a way to opt out of the isScrolling updates.
As a reference, a backwards incompatible way to cover all cases could've been an optional onScrollUpdate callback that got called with the isScrolling and offset values, allowing the consumer to decide even to trigger a rerender by updating state.
I'm seeing this issue too. As I'm scrolling, it re-renders the entire table on what looks like every frame. I had to memoize all the rows, and I'll probably have to memoize all the filters/search bar, etc. Is this considered normal behavior?
I'm seeing this issue too. As I'm scrolling, it re-renders the entire table on what looks like every frame. I had to memoize all the rows, and I'll probably have to memoize all the filters/search bar, etc. Is this considered normal behavior?
@zlanich Good question. Just to clarify first, it’s not literally every frame, but I agree it can look like that during scroll.
What’s happening today is that during scrolling we do update virtual state frequently, like isScrolling which can cause parent components to re-render
That said, I agree this isn’t optimal, and we’re actively looking at improvements here. One direction we’re working on is:
only triggering React re-renders when items are actually added / removed
and for pure position changes, mutating positions internally instead of forcing a full re-render
Optimizations like this are somewhat tied to which virtualization strategy/layout you pick (table vs divs, transform vs top/absolute positioning, different measurement approaches, etc.), so it’s hard to ship a single “perfect out-of-the-box” behavior that always wins. But in practice this should significantly reduce the “everything re-renders while scrolling” effect.
One important design point with TanStack Virtual is that it’s intentionally easy to extend / override default behavior. If you want to reduce React work during scroll today, you can patch onChange so React only re-renders when the range / scrolling state changes, while positions + totalSize are applied via DOM mutations.
constusePatchVirtualizer=(virtualizer: Virtualizer<HTMLDivElement,HTMLTableRowElement>)=>{constrerender=React.useReducer(()=>({}),{})[1];constprev=React.useRef<{range: {startIndex: number;endIndex: number}|null;totalSize: number;positions: Map<any,number>;isScrolling: boolean;}>({range: null,totalSize: 0,positions: newMap(),isScrolling: false});constonChange=(instance: Virtualizer<HTMLDivElement,HTMLTableRowElement>,fromScrollEvent: boolean)=>{constitems=instance.getVirtualItems();consttotalSize=instance.getTotalSize();if(prev.current.totalSize!==totalSize){constfirstItem=items[0];constel=instance.elementsCache.get(firstItem?.key??'')?.parentElement;if(el){prev.current.totalSize=totalSize;el.style.height=`${totalSize}px`;}}constpositions=newMap<any,number>();items.forEach((item)=>{positions.set(item.key,item.start);});for(const[key,nextValue]ofpositions){constprevValue=prev.current.positions.get(key);if(prevValue!==nextValue){constel=instance.elementsCache.get(key);if(el){prev.current.positions.set(key,nextValue);el.style.transform=`translateY(${nextValue-instance.options.scrollMargin}px)`;}}}if(prev.current.isScrolling!==instance.isScrolling||// if isScrolling is not imporntat for you comment remove this line prev.current.range?.startIndex!==instance.range?.startIndex||prev.current.range?.endIndex!==instance.range?.endIndex){prev.current.isScrolling=instance.isScrolling;prev.current.range=instance.range;if(fromScrollEvent){flushSync(rerender)}else{rerender()}}};virtualizer.options.onChange=onChange;};
@piecyk Those optimizations look they would make a world of difference, I'm seeing 50-100 renders just scrolling continuously up or down around a 300-500px with a pretty big overscan and no new items being added, so it's very taxing. I've memoized the crap out of everything, but it's not ideal, because memoizing the row itself prevents the inner components from being able to access any other table state, and memoizing every cell is "fine" but noisy.
Thank you for showing me how to patch the virtualizer. I'm going to play with this and report back if I have anymore questions or interesting findings.
@zlanich Yeah, I agree that memoizing everything isn’t really a great solution. It can help mask the problem, but it quickly becomes noisy and limits what the row/cell components can do.
Also just to clarify one thing, overscan itself doesn’t really decide whether items get added or removed from the virtual range. The item size is the main factor there. If items are small, the virtualizer will naturally add/remove more items while scrolling because more of them fit into the viewport. Overscan just increases the buffer around the visible range, it adds extra items on top, but it doesn’t change the fundamental behavior.
Looking forward to hearing your findings 👍 I’m already using this optimization in a few projects and it’s been working pretty well so far.
@piecyk That's awesome news! I did try your previous method, and I suspect I implemented part of it wrong, as it was causing a lot of weird shifting in the table rows and column widths. I intend to play around with it more, but I'm also very interested in testing out the experimental hook. What is the recommended way to pull this into my existing project to test on my real table?
@zlanich Glad to hear you're interested in trying it 🙂
At the moment it's a bit hard to ship this as a generic solution because it's more of a rendering strategy optimization than a simple library change. Different setups (table layout vs divs, transform vs top positioning, dynamic measurements, column sizing, etc.) behave differently, so a one-size-fits-all patch can easily introduce layout shifts like the ones you saw.
If you describe what you're using, I can try to sketch a version of the patch that should work for your case, which you can then test directly in your project.
I'm very interested in rendering optimization. Could you please explain which strategy (transform or top spacing) your patch is suitable for?
@MatchuPitchu The main idea is, only trigger React re-renders when the virtual range changes (items added/removed), handle pure position updates via DOM mutations
So the idea is not really tied to one specific strategy, it’s more about moving pure position updates out of React and applying them directly to the DOM, while only triggering React re-renders when the virtual range actually changes.
With directDomUpdates: true, scroll-only updates no longer need to go through React. The virtualizer writes item positions and the container size directly to the DOM, and React re-renders should be limited to cases where the visible index range changes or isScrolling changes.
@piecyk This is great! Curious, what led you to make this false by default? It doesn't appear there is any loss in functionality by turning it on. Would it not be a net perf gain to enable it by default?
@piecyk This is great! Curious, what led you to make this false by default? It doesn't appear there is any loss in functionality by turning it on. Would it not be a net perf gain to enable it by default?
@garand the main reason we didn’t enable it by default is that doing so would be a breaking change. Even though it may be a net perf gain and not remove functionality, changing the default behavior could still affect existing users who rely on the current semantics. So we kept it opt-in for now.
Activity
mogelbrod commentedon Dec 29, 2025
I'm seeing the same issue, but with the regular
useVirtualizer(). I wonder if this is a recent change?piecyk commentedon Dec 30, 2025
@neoantox what’s happening is that the item height is relatively small, so while scrolling, items are frequently being added to and removed from the viewport. Each of those changes triggers a re-render, which is expected behavior for a virtualized list.
Additionally, re-renders also occur when scrolling starts and stops because the isScrolling flag is toggled during those transitions.
So the re-renders are not happening on every scroll tick arbitrarily, but as a result of viewport changes and scroll state updates. https://stackblitz.com/edit/tanstack-virtual-juvs8dfo?file=src%2Fmain.tsx
Nagranie.z.ekranu.2025-12-30.o.07.53.49.mov
@mogelbrod not really, how did you notice this behavior? Did you observe a change in render counts compared to a previous version, or notice a regression after an update?
mogelbrod commentedon Dec 30, 2025
I just happened to be debugging another issue and noticed that the component consuming
useVirtualizer()gets re-rendered constantly while scrolling, no matter how slowly or short distance. There's probably 1-2 years since I last actively worked with react-virtualizer, but IIRC back then it didn't trigger this many renders.I cloned @neoantox repro and modified it to instead use
useVirtualizer()here. Simply scrolling 1-3 pixels down triggers multiple re-renders of the visible rows, which is not what I expected.Looking at the docs, having
useVirtualizer()returnscrollOffsetseems like a possible culprit. If that value is tracked using regular react state then that would seem like the reason why the hook triggers re-renders this often. #352 from 2022 appears to raise the same issue. I'd assume this value would be something consumers of the hook could opt-in to as I'm guessing most won't be using it.piecyk commentedon Dec 30, 2025
@mogelbrod Thanks, I’ll try to debug it more.
Note that even when the visible range doesn’t change, useVirtualizer() will still trigger re-renders due to isScrolling changing state (typically one render when scrolling starts and another when it stops). That can happen even with very small scroll deltas like 1–3px.
We don’t trigger re-renders on scrollOffset changes themselves, otherwise you’d see many more re-renders while scrolling. You can see this clearly in the clip I shared.
mogelbrod commentedon Dec 30, 2025
Gotcha, thanks for clarifying!
Is there a way to opt-out of the isScrolling updates? I'm guessing a large number of use cases ignore that state entirely, and it would be nice to avoid rerendering unnecessarily.
As a reference, a backwards incompatible way to cover all cases could've been an optional onScrollUpdate callback that got called with the isScrolling and offset values, allowing the consumer to decide when to trigger a rerender by updating state.
piecyk commentedon Dec 30, 2025
Currently, no, there isn’t a way to opt out of the isScrolling updates.
Yeah, sounds logical
zlanich commentedon Feb 22, 2026
I'm seeing this issue too. As I'm scrolling, it re-renders the entire table on what looks like every frame. I had to memoize all the rows, and I'll probably have to memoize all the filters/search bar, etc. Is this considered normal behavior?
piecyk commentedon Feb 23, 2026
@zlanich Good question. Just to clarify first, it’s not literally every frame, but I agree it can look like that during scroll.
What’s happening today is that during scrolling we do update virtual state frequently, like isScrolling which can cause parent components to re-render
That said, I agree this isn’t optimal, and we’re actively looking at improvements here. One direction we’re working on is:
Optimizations like this are somewhat tied to which virtualization strategy/layout you pick (table vs divs, transform vs top/absolute positioning, different measurement approaches, etc.), so it’s hard to ship a single “perfect out-of-the-box” behavior that always wins. But in practice this should significantly reduce the “everything re-renders while scrolling” effect.
One important design point with TanStack Virtual is that it’s intentionally easy to extend / override default behavior. If you want to reduce React work during scroll today, you can patch onChange so React only re-renders when the range / scrolling state changes, while positions + totalSize are applied via DOM mutations.
Like https://stackblitz.com/edit/tanstack-table-ceq8kglh?file=src%2Fmain.tsx&preset=node
Then call it after getting instance
zlanich commentedon Feb 27, 2026
@piecyk Those optimizations look they would make a world of difference, I'm seeing 50-100 renders just scrolling continuously up or down around a 300-500px with a pretty big overscan and no new items being added, so it's very taxing. I've memoized the crap out of everything, but it's not ideal, because memoizing the row itself prevents the inner components from being able to access any other table state, and memoizing every cell is "fine" but noisy.
Thank you for showing me how to patch the virtualizer. I'm going to play with this and report back if I have anymore questions or interesting findings.
Thank you again for taking the time to help!
piecyk commentedon Mar 5, 2026
@zlanich Yeah, I agree that memoizing everything isn’t really a great solution. It can help mask the problem, but it quickly becomes noisy and limits what the row/cell components can do.
Also just to clarify one thing, overscan itself doesn’t really decide whether items get added or removed from the virtual range. The item size is the main factor there. If items are small, the virtualizer will naturally add/remove more items while scrolling because more of them fit into the viewport. Overscan just increases the buffer around the visible range, it adds extra items on top, but it doesn’t change the fundamental behavior.
Looking forward to hearing your findings 👍 I’m already using this optimization in a few projects and it’s been working pretty well so far.
piecyk commentedon Mar 9, 2026
Was playing around with this a bit #1141, manage to run it against the current e2e test we got
Performance Comparison:
useVirtualizervsuseExperimentalDOMVirtualizerAll 32 tests passed (34.7s).
useVirtualizeruseExperimentalVirtualizerKey Takeaways
zlanich commentedon Mar 9, 2026
@piecyk That's awesome news! I did try your previous method, and I suspect I implemented part of it wrong, as it was causing a lot of weird shifting in the table rows and column widths. I intend to play around with it more, but I'm also very interested in testing out the experimental hook. What is the recommended way to pull this into my existing project to test on my real table?
Thank you!
piecyk commentedon Mar 9, 2026
@zlanich Glad to hear you're interested in trying it 🙂
At the moment it's a bit hard to ship this as a generic solution because it's more of a rendering strategy optimization than a simple library change. Different setups (table layout vs divs, transform vs top positioning, dynamic measurements, column sizing, etc.) behave differently, so a one-size-fits-all patch can easily introduce layout shifts like the ones you saw.
If you describe what you're using, I can try to sketch a version of the patch that should work for your case, which you can then test directly in your project.
MatchuPitchu commentedon Mar 10, 2026
@piecyk I'm very interested in rendering optimization. Could you please explain which strategy (transform or top spacing) your patch is suitable for?
piecyk commentedon Mar 10, 2026
@MatchuPitchu The main idea is, only trigger React re-renders when the virtual range changes (items added/removed), handle pure position updates via DOM mutations
piecyk commentedon Mar 10, 2026
@MatchuPitchu It should work in principle with any strategies, the main difference is just how the position update is applied.
For example, with top/offset positioning you’d do:
and with transform positioning:
So the idea is not really tied to one specific strategy, it’s more about moving pure position updates out of React and applying them directly to the DOM, while only triggering React re-renders when the virtual range actually changes.
piecyk commentedon Jun 2, 2026
This should now be covered by the new
directDomUpdatesoption:https://tanstack.com/virtual/latest/docs/framework/react/react-virtual#directdomupdates
With
directDomUpdates: true, scroll-only updates no longer need to go through React. The virtualizer writes item positions and the container size directly to the DOM, and React re-renders should be limited to cases where the visible index range changes orisScrollingchanges.garand commentedon Jun 2, 2026
@piecyk This is great! Curious, what led you to make this
falseby default? It doesn't appear there is any loss in functionality by turning it on. Would it not be a net perf gain to enable it by default?piecyk commentedon Jun 2, 2026
@garand the main reason we didn’t enable it by default is that doing so would be a breaking change. Even though it may be a net perf gain and not remove functionality, changing the default behavior could still affect existing users who rely on the current semantics. So we kept it opt-in for now.
garand commentedon Jun 2, 2026
Makes sense, appreciate the info. 🫡