<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: J Lopes</title>
    <description>The latest articles on DEV Community by J Lopes (@offeringofpie).</description>
    <link>https://dev.to/offeringofpie</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2599165%2F272578aa-1f03-4256-a581-f9686bd82510.png</url>
      <title>DEV Community: J Lopes</title>
      <link>https://dev.to/offeringofpie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/offeringofpie"/>
    <language>en</language>
    <item>
      <title>Understanding List Virtualization</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:37:02 +0000</pubDate>
      <link>https://dev.to/offeringofpie/understanding-list-virtualization-54fk</link>
      <guid>https://dev.to/offeringofpie/understanding-list-virtualization-54fk</guid>
      <description>&lt;p&gt;If you don't know what &lt;a href="https://www.patterns.dev/vanilla/virtual-lists/" rel="noopener noreferrer"&gt;virtualization&lt;/a&gt; is, you probably don't need it.&lt;/p&gt;

&lt;p&gt;It essentially renders only the items that need to be visible in the UI. Most lists are short, most grids are small, and the browser renders them without complaint.&lt;/p&gt;

&lt;p&gt;I didn't need it either, until I decided to put the entire Unicode table on a page.&lt;/p&gt;

&lt;p&gt;The whole idea fits in one running component: ten thousand cells in a grid, with only the visible handful in the DOM. There's a live demo of it in &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;the original post on jlopes.eu&lt;/a&gt;; scroll it, then flip to the code tab.&lt;/p&gt;

&lt;p&gt;A quick word on what "in the DOM" means, because the rest of this article hangs on it. The DOM is the browser's live model of the page: every div, button, and span is a node in one big tree, and the browser has to lay each node out, paint it, and keep it in memory. A few hundred nodes are nothing. Tens of thousands are a problem, and the problem compounds when they keep changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  In practice, the Character map
&lt;/h2&gt;

&lt;p&gt;I built a tool called &lt;a href="https://jlopes.eu/tools/character-map" rel="noopener noreferrer"&gt;Character map&lt;/a&gt;. It scans over 30,000 code points (Unicode's term for one character), loaded once and cached in the browser's built-in database IndexedDB, so the table doesn't re-download on every visit. You browse it through a grid of tiles. The filtered results are capped at 2,000, and I assumed that ceiling would keep it smooth.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;**Narrator&lt;/em&gt;&lt;em&gt;: It didn't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first version rendered every result, so a single filter pass produced 2,000 buttons. Scrolling stuttered, and resizing was worse because the browser had to recalculate the position of every tile in the grid.&lt;/p&gt;

&lt;p&gt;Search is debounced at 150ms, meaning it waits until you've paused typing for 150ms before running the filter, and even so, each pause re-ran the filter, threw away the grid, and rebuilt it.&lt;/p&gt;

&lt;p&gt;Memory climbed while I typed. Two thousand rows are nothing to a database, but two thousand live elements in a reflowing grid were plenty to kill page performance.&lt;/p&gt;

&lt;p&gt;It took me a while to realise that there were two costs involved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One is the number of DOM nodes.&lt;/li&gt;
&lt;li&gt;The other is how often the existing nodes rebuild.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Virtualization fixes the first and, on its own, does nothing about the second. We'll get to the second thing later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lying to the scrollbar
&lt;/h2&gt;

&lt;p&gt;At any moment, the viewport (the visible area of the scroll container) shows a few rows. Everything above and below is off-screen and, as far as the user can tell, doesn't need to exist. So you keep the visible rows in the DOM plus a small buffer, and drop the rest. As you scroll, the top and bottom rows are mounted/unmounted depending on the direction. The user sees a full list, and the browser only cares about a few rows.&lt;/p&gt;

&lt;p&gt;The catch is the scrollbar. A scrollbar's length is as big as the content, so if only twenty rows exist, you reach the bottom and ruin the effect. It has to keep lying, or the whole effect feels broken. The lie is a single spacer element that wraps the existing rows, sized to the height the rows would occupy if they all existed at the same time. The rows you mount are placed on top of that spacer with a &lt;code&gt;translateY&lt;/code&gt; offset, shifting elements down by a few pixels without breaking the layout.&lt;/p&gt;

&lt;p&gt;Which rows to mount now will depend on arithmetic. You always know the scroll position and the row height, so dividing one by the other tells you which row index sits at the top edge of the viewport; add the viewport height, and you have the bottom edge. React &lt;a href="https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists" rel="noopener noreferrer"&gt;called this windowing&lt;/a&gt;, and &lt;a href="https://web.dev/articles/virtualize-long-lists-react-window" rel="noopener noreferrer"&gt;web.dev walks through the same pattern with react-window&lt;/a&gt;. The mechanics are identical in Vue.&lt;/p&gt;

&lt;p&gt;Stripped of framework, the whole geometry is three numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spacerHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;viewportHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// each mounted row sits at translateY(rowIndex * rowHeight)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the rows from &lt;code&gt;startRow&lt;/code&gt; to &lt;code&gt;endRow&lt;/code&gt; exist. Everything the scrollbar believes about the rest of the content comes from the spacer. And the whole trick is the division. For example: rows are 80px tall, you've scrolled 800px, so row 10 sits at the top edge. Nothing gets searched or measured.&lt;/p&gt;

&lt;p&gt;The payoff is that the DOM node count stops tracking the dataset. Twenty rows on screen means twenty-odd rows in the DOM, whether the list holds two thousand entries or two hundred thousand. As far as the layout engine is concerned, the other 199,980 rows are a myth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grid is a list of rows in a trench coat
&lt;/h2&gt;

&lt;p&gt;This is where my grid diverged from the tutorials. A &lt;a href="https://tanstack.com/virtual/latest/docs/introduction" rel="noopener noreferrer"&gt;virtualiser&lt;/a&gt;, the library that handles the windowing bookkeeping for you, thinks in lists: one item per index, stacked vertically, each taking the full width. My tiles sit twelve to a row. Hand the virtualiser the flat array of tiles, and it will earnestly stack 2,000 full-width rows of one glyph each, and every offset it calculates is wrong.&lt;/p&gt;

&lt;p&gt;The fix is to change what an item means. Slice the flat list into groups of twelve first, and make each group the virtual item. The virtualiser counts and positions rows, never knowing whether they contain cells; a plain CSS grid lays the tiles out inside each one.&lt;/p&gt;

&lt;p&gt;The demo in the original post does all of this by hand: reads &lt;code&gt;scrollTop&lt;/code&gt;, divides by the row height, and mounts the window. For the Character map, I used &lt;a href="https://tanstack.com/virtual/latest" rel="noopener noreferrer"&gt;TanStack&lt;/a&gt;'s &lt;code&gt;useVirtualizer&lt;/code&gt; instead, purely for simplicity's sake: someone has to own the scroll listener, the range maths, and the offsets, and I'd rather it wasn't me.&lt;/p&gt;

&lt;p&gt;In code, that's one computed and one hook. A computed is Vue's memoised derived value; it recalculates only when something changes. &lt;code&gt;rows&lt;/code&gt; does the slicing, and the virtualiser gets told three things: how many rows there are, which element scrolls, and that each row is about 80px tall. Trimmed from the Character map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;[][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;filteredChars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filteredChars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;virtualGrid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useVirtualizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;getScrollElement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;estimateSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;overscan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template renders only the visible rows, each with its own grid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"grid"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"overflow-y-auto h-[60vh] min-h-80"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ height: `${virtualGrid.getTotalSize()}px`, position: 'relative' }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
        &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"row in virtualGrid.getVirtualItems()"&lt;/span&gt;
        &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"row.key"&lt;/span&gt;
        &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-3 lg:grid-cols-12 absolute w-full"&lt;/span&gt;
        &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ height: `${row.size}px`, transform: `translateY(${row.start}px)` }"&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"c in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"c.cp"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"openDetails(c)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          {{ c.char }}
        &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details hide in that setup. &lt;code&gt;estimateSize: () =&amp;gt; 80&lt;/code&gt; is a promise that every row is 80px tall, so the virtualiser never has to measure anything. And in the real component &lt;code&gt;columns&lt;/code&gt; isn't a constant: it's its own computed fed by a breakpoint helper, 12 on desktop and 3 on mobile...&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about overscan
&lt;/h2&gt;

&lt;p&gt;Overscan is the number of extra rows past the visible edge that stay mounted, a buffer above and below the window. In the window math from earlier, it widens both edges before they're clamped to the ends of the list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;overscan&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rowCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;viewportHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;overscan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CharacterMap keeps 5, and I found that number by getting it wrong first. At 1 or 2, a fast flick on a phone flashed a blank space at the leading edge, so for a frame or two, there was simply nothing there. Raising it gave the browser rows in reserve, but I took care to make sure I wasn't causing slowness by trying to keep the illusion. I added and removed until there was no "blank" issue for my scrolling speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Later, getting to the second thing
&lt;/h2&gt;

&lt;p&gt;Windowing fixes node count. It does nothing about how often the surviving rows re-render, and a virtualised grid re-renders a lot. Every scroll frame moves the window. If each visible row re-renders every time, we're just putting a tiny band-aid on a big gash. Both React and Vue tackle the issue differently: how does a row skip re-rendering when nothing about it has changed?&lt;/p&gt;

&lt;p&gt;React re-renders by re-running the component and diffing the result against the last one. Normally when a component re-renders, its children re-render too, whether their inputs changed or not. In a virtualised grid, that means a scroll-driven state change can re-invoke every mounted row. React's way out is to mark the components that can skip updates by wrapping them in &lt;a href="https://react.dev/reference/react/memo#skipping-re-rendering-when-props-are-unchanged" rel="noopener noreferrer"&gt;&lt;code&gt;memo()&lt;/code&gt;&lt;/a&gt;, comparing each prop with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is" rel="noopener noreferrer"&gt;&lt;code&gt;Object.is&lt;/code&gt;&lt;/a&gt;. This method compares references, and for example two objects with identical contents are still two different objects. In JavaScript, &lt;code&gt;{} === {}&lt;/code&gt; is false. &lt;code&gt;memo()&lt;/code&gt; compares against the last rendered version, and if it sees a different reference, the row is re-rendered. &lt;a href="https://react.dev/reference/react/useMemo" rel="noopener noreferrer"&gt;&lt;code&gt;useMemo&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://react.dev/reference/react/useCallback" rel="noopener noreferrer"&gt;&lt;code&gt;useCallback&lt;/code&gt;&lt;/a&gt; exist for exactly this, keeping references stable across renders, so &lt;code&gt;memo&lt;/code&gt; has something equal to find.&lt;/p&gt;

&lt;p&gt;In row form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onOpen&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;RowProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// no memo: new in every render, props are never equal&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt; &lt;span class="na"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// memo: the reference is the same one memo saw last time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openDetails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt; &lt;span class="na"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;openDetails&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vue starts from the other side. A parent updating doesn't drag its children along unless their props changed too. The compiler adds its own layer, knowing which parts of a template can change and which never will. Much of what &lt;code&gt;memo&lt;/code&gt; does in React is the default here. When you do want manual control, &lt;a href="https://vuejs.org/api/built-in-directives.html#v-memo" rel="noopener noreferrer"&gt;&lt;code&gt;v-memo&lt;/code&gt;&lt;/a&gt; freezes a piece of the template until the values you pick change, and &lt;code&gt;computed&lt;/code&gt; covers the derived state.&lt;/p&gt;

&lt;p&gt;In the Character map, this shows up as a chain of &lt;code&gt;computed()&lt;/code&gt;, each link rerunning only when the one before it changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filteredChars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;debouncedQuery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;characters&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activeGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;activeGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxResults&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rows&lt;/code&gt; recomputes only when &lt;code&gt;filteredChars&lt;/code&gt; or &lt;code&gt;columns&lt;/code&gt; change, and the virtualiser recounts only when &lt;code&gt;rows&lt;/code&gt; changes. Scrolling does not affect that chain. It moves the window and leaves the mounted rows alone.&lt;/p&gt;

&lt;p&gt;Both frameworks can reach the same end, but React makes you opt out of re-rendering and Vue makes you opt in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to break the illusion
&lt;/h2&gt;

&lt;p&gt;Two more things bit me, and neither is related to React or Vue:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The container needs a real height. The fix for me was a fixed height on the scroll container (&lt;code&gt;60vh&lt;/code&gt;) plus &lt;code&gt;overflow-y-auto&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keys have to carry an identity. With a bad key, nodes would get recycled whenever the cell props changed.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My first version keyed tiles by array index, and this didn't give great results... it would re-filter on every keystroke and toggle whenever the node in slot 0 had an id of 0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- index: look at me, I'm the index 0 now --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"(c, i) in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"i"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ c.char }}&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- identity: oh, hi mark! --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"c in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"c.cp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ c.char }}&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With each node bound to its own datum, the nodes stopped rerendering. Index keys look fine right up until the previous item moves away, and in a search UI, items tend to move constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round two: Icon Search
&lt;/h2&gt;

&lt;p&gt;All of this got a second run. &lt;a href="https://jlopes.eu/tools/icon-search/" rel="noopener noreferrer"&gt;Icon Search&lt;/a&gt; merges seven icon libraries (Heroicons, Lucide, Phosphor, Solar, Font Awesome, Carbon, Simple Icons) into one searchable set that runs into the tens of thousands.&lt;br&gt;
The tiles now have an inline SVG instead of a text glyph. But with the learnings from the previous tool, the pattern used was nearly untouched. Filtered results are capped at 2,000, chunked into rows, and virtualised by row.&lt;/p&gt;

&lt;p&gt;The biggest change was row height: IconSearch tiles carry a label that can wrap to a second line, so rows aren't a uniform height anymore. That matters more than it sounds, because every row's offset is the sum of the heights of all the rows above it. Dynamic height is a two-step process rather than a smarter estimate. &lt;code&gt;estimateSize&lt;/code&gt; still runs first, and its guess (88px here) positions rows on the initial render. Then, as each row mounts, a ref callback hands the node to the virtualiser's &lt;a href="https://tanstack.com/virtual/latest/docs/api/virtualizer#measureelement" rel="noopener noreferrer"&gt;&lt;code&gt;measureElement&lt;/code&gt;&lt;/a&gt;, which reads the real rendered height via &lt;code&gt;getBoundingClientRect()&lt;/code&gt; and replaces the cached estimate. The &lt;code&gt;data-index&lt;/code&gt; attribute tells it which row the node belongs to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
  &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"row in rowVirtualizer.getVirtualItems()"&lt;/span&gt;
  &lt;span class="na"&gt;:ref=&lt;/span&gt;&lt;span class="s"&gt;"(el) =&amp;gt; rowVirtualizer.measureElement(el as Element)"&lt;/span&gt;
  &lt;span class="na"&gt;:data-index=&lt;/span&gt;&lt;span class="s"&gt;"row.index"&lt;/span&gt;
  &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-4 lg:grid-cols-12 absolute w-full"&lt;/span&gt;
  &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ transform: `translateY(${row.start}px)` }"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- tiles for rows[row.index] --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In cases like this, it's best to window the DOM so node count stops tracking the dataset. And then slice the grid into rows so the window has something list-shaped to travel over. Tune overscan by getting it wrong in both directions and settling in the middle. And keep the mounted rows idle when nothing about them has changed: React makes you opt out of re-rendering, Vue mostly handles it by default.&lt;/p&gt;

&lt;p&gt;Both tools are open source: &lt;a href="https://github.com/offeringofpie/tools/blob/main/app/components/global/CharacterMap.vue" rel="noopener noreferrer"&gt;CharacterMap.vue&lt;/a&gt; and &lt;a href="https://github.com/offeringofpie/tools/blob/main/app/components/global/IconSearch.vue" rel="noopener noreferrer"&gt;IconSearch.vue&lt;/a&gt;. This article was originally published at &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;jlopes.eu&lt;/a&gt;, where the interactive demo lives.&lt;/p&gt;

</description>
      <category>react</category>
      <category>vue</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
