Skip to content

Virtualized list re-renders on every scroll frame #1062

Closed

Description

@neoantox

Describe the bug

I'm using useWindowVirtualizer and noticing that on scroll, the list and each visible row re-render on every scroll tick.

Your minimal, reproducible example

https://stackblitz.com/edit/tanstack-virtual-rnakrfql?file=src%2Fmain.tsx

Steps to reproduce

Just scroll the list slightly

Expected behavior

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.

Activity

  1. mogelbrod commented on Dec 29, 2025

    @mogelbrod
    Contributor

    I'm seeing the same issue, but with the regular useVirtualizer(). I wonder if this is a recent change?

  2. piecyk commented on Dec 30, 2025

    @piecyk
    Collaborator

    @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?

  3. mogelbrod commented on Dec 30, 2025

    @mogelbrod
    Contributor

    @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.

  4. piecyk commented on Dec 30, 2025

    @piecyk
    Collaborator

    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.

    @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.

    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.

  5. mogelbrod commented on Dec 30, 2025

    @mogelbrod
    Contributor

    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.

  6. piecyk commented on Dec 30, 2025

    @piecyk
    Collaborator

    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.

    Yeah, sounds logical

  7. zlanich commented on Feb 22, 2026

    @zlanich

    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?

  8. piecyk commented on Feb 23, 2026

    @piecyk
    Collaborator

    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.

    Like https://stackblitz.com/edit/tanstack-table-ceq8kglh?file=src%2Fmain.tsx&preset=node

    const usePatchVirtualizer = (
      virtualizer: Virtualizer<HTMLDivElement, HTMLTableRowElement>
    ) => {
      const rerender = React.useReducer(() => ({}), {})[1];
    
      const prev = React.useRef<{
        range: { startIndex: number; endIndex: number } | null;
        totalSize: number;
        positions: Map<any, number>;
        isScrolling: boolean;
      }>({ range: null, totalSize: 0, positions: new Map(), isScrolling: false });
    
      const onChange = (
        instance: Virtualizer<HTMLDivElement, HTMLTableRowElement>, fromScrollEvent: boolean
      ) => {
        const items = instance.getVirtualItems();
        const totalSize = instance.getTotalSize();
    
        if (prev.current.totalSize !== totalSize) {
          const firstItem = items[0];
          const el = instance.elementsCache.get(
            firstItem?.key ?? ''
          )?.parentElement;
          if (el) {
            prev.current.totalSize = totalSize;
            el.style.height = `${totalSize}px`;
          }
        }
    
        const positions = new Map<any, number>();
        items.forEach((item) => {
          positions.set(item.key, item.start);
        });
    
        for (const [key, nextValue] of positions) {
          const prevValue = prev.current.positions.get(key);
          if (prevValue !== nextValue) {
            const el = 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;
    };

    Then call it after getting instance

    const rowVirtualizer = useVirtualizer({
      count: rows.length,
      ...
    })
    usePatchVirtualizer(rowVirtualizer);
  9. zlanich commented on Feb 27, 2026

    @zlanich

    @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!

  10. piecyk commented on Mar 5, 2026

    @piecyk
    Collaborator

    @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.

  11. piecyk commented on Mar 9, 2026

    @piecyk
    Collaborator

    Was playing around with this a bit #1141, manage to run it against the current e2e test we got

    Performance Comparison: useVirtualizer vs useExperimentalDOMVirtualizer

    All 32 tests passed (34.7s).

    Metric useVirtualizer useExperimentalVirtualizer Difference
    Initial render 30.1ms, 3 renders 26.3ms, 3 renders ~13% faster
    Scroll 200×100px 60.0 fps, 407 renders 59.9 fps, 215 renders 47% fewer renders
    Scroll 500×20px 60.1 fps, 410 renders 60.1 fps, 264 renders 36% fewer renders
    scrollToIndex(5000) 4 renders 5 renders similar
    Round-trip (9999→0) 6 renders 6 renders identical

    Key Takeaways

    • The experimental hook cuts React render count nearly in half during continuous scrolling while maintaining the same 60fps.
    • Both hooks achieve identical frame rates — the render savings translate to lower CPU usage and better battery life.
    • The difference will be more pronounced on slower devices or with heavier item components.
  12. zlanich commented on Mar 9, 2026

    @zlanich

    @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!

  13. piecyk commented on Mar 9, 2026

    @piecyk
    Collaborator

    @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.

  14. MatchuPitchu commented on Mar 10, 2026

    @MatchuPitchu

    @piecyk I'm very interested in rendering optimization. Could you please explain which strategy (transform or top spacing) your patch is suitable for?

  15. piecyk commented on Mar 10, 2026

    @piecyk
    Collaborator

    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

  16. piecyk commented on Mar 10, 2026

    @piecyk
    Collaborator

    @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:

    el.style.top = `${nextValue - instance.options.scrollMargin}px`

    and with transform positioning:

    el.style.transform = `translateY(${nextValue - instance.options.scrollMargin}px)

    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.

  17. piecyk commented on Jun 2, 2026

    @piecyk
    Collaborator

    This should now be covered by the new directDomUpdates option:

    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 or isScrolling changes.

    const virtualizer = useWindowVirtualizer({
      count,
      estimateSize: () => 50,
      directDomUpdates: true,
    })
  18. garand commented on Jun 2, 2026

    @garand

    @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?

  19. piecyk commented on Jun 2, 2026

    @piecyk
    Collaborator

    @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.

  20. garand commented on Jun 2, 2026

    @garand

    Makes sense, appreciate the info. 🫡

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions