Blog

Part 35 · Virtualization: Showing Long Lists Fast

A list of 10,000 rows puts 10,000 tags on the page. Learn to show only the rows that fit in a box, build one from scratch, and see what it costs.

In Part 8 you turned an array into a list with map. That works well for 10 rows, or 100. But some apps have 10,000 rows: a long chat, a log, a big table.

In Part 19 we said that some apps “show fewer items at once”. This part is about that idea. It has two names: windowing and virtualization. Both mean the same thing here. You keep all the rows in your array, but you put only the rows people can see on the page.

We will build one from scratch, with no library. It uses things you already know: state, onScroll, keys, and a little math. Then we look at what it takes away, and when you don’t need it at all.

Try this first

A pixel is one of the tiny dots that make up the screen. This list has 10,000 rows. They sit in a box that is 300 pixels tall, so you can see only about 12 of them at a time. The button counts the rows that are on the page right now.

import { useRef, useState } from 'react'

const rows = Array.from({ length: 10000 }, (_, i) => `Row ${i + 1}`)

export default function App() {
  const listRef = useRef<HTMLUListElement>(null)
  const [count, setCount] = useState('not counted yet')

  function handleCount() {
    if (listRef.current === null) return
    setCount(String(listRef.current.children.length))
  }

  return (
    <div>
      <button onClick={handleCount}>Count the rows on the page</button>
      <p>Rows on the page: {count}</p>
      <div style={{ height: 300, overflowY: 'auto', border: '1px solid gray' }}>
        <ul ref={listRef}>
          {rows.map(row => <li key={row}>{row}</li>)}
        </ul>
      </div>
    </div>
  )
}

Array.from({ length: 10000 }, ...) makes an array of 10,000 texts, from “Row 1” to “Row 10000”. overflowY: 'auto' gives the box a scroll bar when its content is taller than the box. listRef.current.children holds the tags directly inside the <ul>. Part 14 explained refs.

Make a guess before you press Run. You can see about 12 rows. How many <li> tags are on the page?

Now press Run, then the button.

It says 10000. Every row has its own <li> on the page, even the 9,988 rows you can’t see. We checked this in Chromium, Firefox and WebKit. In all three, the page in the result box had 10,017 tags in total, and 10,000 of them were <li>.

Why so many tags are slow

The browser keeps an object for every tag on the page. Part 14 called these objects DOM nodes. A list of 10,000 rows has more than 10,000 of them, and each one costs something.

Google’s web.dev guide on page size names the costs.

  • A bigger page is slower to show the first time. The browser must work out the style of every tag, and its size and place. Working out size and place is called layout.
  • A bigger page is slower to change. When you add, remove or change tags, the browser may have to do layout again. The guide says this can make the page slow to answer a click. The same guide says Lighthouse, a tool that checks web pages, warns about a page with more than 800 nodes. It calls more than 1,400 nodes “excessive”, which means too many. Our list has more than 10,000.

There is a third cost: memory, where a program keeps its data while it runs. The guide only touches on it. It says JavaScript that holds on to many nodes, like the answer from document.querySelectorAll, can cost a lot of memory. So we measured the page’s memory ourselves.

We used Chromium, on our own computer, and ran each list in a fresh page three times. The full list added 30,036 nodes and 9.7 MB of JavaScript memory each time. The windowed list you’ll build below added 81 nodes and about 4.4 MB. Part of both numbers is the playground loading React. Your numbers will be different on another computer.

Why 30,036 nodes for 10,000 rows? Chromium counts three nodes for each row: the <li>, the text inside it, and the bullet in front of it. We checked with 10,000 plain <li> tags. With bullets, Chromium counted 30,002 new nodes. With the bullets turned off, it counted 20,002.

React adds its own cost. For every row, it calls your code, makes an element and keeps track of it. A row that is never seen still costs all of that.

The idea: put only the rows you can see on the page

Think of the box as a small window on a very tall list. Only the rows in the window need tags on the page. The rows above and below it can stay in the array as plain data.

When you scroll, the window moves down the list. Rows that leave the window lose their tags. Rows that come into it get new tags. So the number of tags stays small, however long the array is.

We also make tags for a few extra rows just outside the window. This is called overscan. You’ll see why it matters soon.

1. The array has 10,000 rows. The box shows only 4 at a time. 2. React makes tags for rows 1 to 5: the 4 you see, plus 1 extra. 3. You scroll down 3 rows. For a moment, rows 6 and 7 have no tags. 4. Rows 1 and 2 lose their tags. Rows 6 to 8 get new tags. 5. Scroll again, and it happens again. The count stays small. Row 1 <li>Row 1</li> Row 2 <li>Row 2</li> Row 3 <li>Row 3</li> Row 4 <li>Row 4</li> Row 5 <li>Row 5</li> Row 6 <li>Row 6</li> Row 7 <li>Row 7</li> Row 8 <li>Row 8</li> Row 9 <li>Row 9</li> Row 10 <li>Row 10</li> Row 11 <li>Row 11</li> Row 12 <li>Row 12</li> and so on, down to Row 10,000 the box's scrollTop 0 tags on the page none yet the box: what you can see has a tag on the page only in the array, no tag

Only the rows near the box have tags. Press play, or step through it with the arrows.

The same steps in words:

  1. The array has 10,000 rows. The box can show only 4 rows at a time.
  2. React makes tags only for rows 1 to 5. That is the 4 rows you see, plus 1 extra below them.
  3. You scroll down 3 rows. The box’s scrollTop is now 90, because each row is 30 pixels tall. The box shows rows 4 to 7. For a moment, rows 6 and 7 have no tags yet.
  4. React renders. Rows 1 and 2 are now far from the box, so React removes their tags. Rows 6 to 8 are near it, so React makes tags for them.
  5. You scroll again, and the same thing happens. In this picture there are only 5 or 6 tags at any time.

An everyday example

Think of a very long street at night, and a person walking down it. The street lights turn on only near the person. Behind them, the lights turn off. Ahead of them, new lights turn on. The person always walks in light, but only a few lights are on at a time.

The exact version

The street lamps exist all the time, even when they are off. Our rows’ tags don’t. A row outside the window has no tag at all. It is only an object in your array. As you scroll, React makes tags for rows that come near the box and removes tags for rows that leave. That is why the browser has so little to do. It is also why some browser features can’t see those rows, as we’ll see below.

Building a list that shows only what fits

We need four things. Let’s take them one at a time, then put them together.

Step 1: a box with a fixed height

The box needs its own height, like height: 300, and overflowY: 'auto'. Then the box scrolls, not the whole page. The box tells us how far it has scrolled in scrollTop. scrollTop is the number of pixels the content has moved up, out of sight. At the start it is 0.

Step 2: a tall list inside it

If we put only 13 rows inside the box, it would have almost nothing to scroll. So we make the <ul> as tall as all the rows together: 10,000 rows × 30 pixels = 300,000 pixels. The scroll bar then looks right, and you can scroll to the very end. Most of that tall <ul> is empty.

Step 3: work out which rows can be seen

Each row is 30 pixels tall, so the math is short. Say the box has scrolled 4,500 pixels:

const ROW_HEIGHT = 30
const BOX_HEIGHT = 300
const scrollTop = 4500

const first = Math.floor(scrollTop / ROW_HEIGHT)
const last = Math.ceil((scrollTop + BOX_HEIGHT) / ROW_HEIGHT)
console.log(first, last)

This prints 150 160. Math.floor rounds down and Math.ceil rounds up. So the first row you can see is at index 150. The rows you can see end just before index 160. Index 150 is “Row 151”, because the index starts at 0.

Then we add the overscan, 3 rows on each side, and take those rows out of the array with slice. Math.max and Math.min keep the numbers inside the array at the top and at the end.

Step 4: put each row in its place

Each row gets position: 'absolute' and a top. top says how many pixels from the top of the <ul> the row sits. Row number index goes at index * ROW_HEIGHT. So a row sits in the same place it would have in the full list.

Here it is, all together. It has the same count button as before.

import { useRef, useState } from 'react'

const ROW_HEIGHT = 30
const BOX_HEIGHT = 300
const OVERSCAN = 3

const items = Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, text: `Row ${i + 1}` }))

export default function App() {
  const [scrollTop, setScrollTop] = useState(0)
  const listRef = useRef<HTMLUListElement>(null)
  const [count, setCount] = useState('not counted yet')

  const first = Math.floor(scrollTop / ROW_HEIGHT)
  const last = Math.ceil((scrollTop + BOX_HEIGHT) / ROW_HEIGHT)
  const start = Math.max(0, first - OVERSCAN)
  const end = Math.min(items.length, last + OVERSCAN)
  const visible = items.slice(start, end)

  function handleCount() {
    if (listRef.current === null) return
    setCount(String(listRef.current.children.length))
  }

  return (
    <div>
      <button onClick={handleCount}>Count the rows on the page</button>
      <p>Rows on the page: {count}</p>
      <p>React shows rows {start + 1} to {end}.</p>
      <div
        onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
        style={{ height: BOX_HEIGHT, overflowY: 'auto', border: '1px solid gray' }}
      >
        <ul
          ref={listRef}
          style={{ position: 'relative', height: items.length * ROW_HEIGHT, margin: 0, padding: 0 }}
        >
          {visible.map((item, i) => (
            <li
              key={item.id}
              style={{ position: 'absolute', top: (start + i) * ROW_HEIGHT, height: ROW_HEIGHT, left: 24, right: 0 }}
            >
              {item.text}
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

A few lines are new:

  • onScroll runs each time the box scrolls. e.currentTarget is the box itself, so e.currentTarget.scrollTop is how far it has scrolled. We keep that number in state. Each scroll changes the state, so React renders App again with the new rows.
  • position: 'relative' on the <ul> makes each row’s top count from the top of the <ul>.
  • start + i is the row’s index in the whole array. i alone is only its place in visible.
  • items.slice(start, end) gives the rows from index start up to, but not including, end.

Press Run, then the count button. It says 13: the 10 rows you can see, plus 3 extra below. The line above the box says “React shows rows 1 to 13.”

Now scroll down a long way and press the button again. We checked this in Chromium, Firefox and WebKit, with the box scrolled to 4,500 pixels:

Where the box is scrollTop Rows React shows <li> on the page Rows you can see
At the top 0 1 to 13 13 1 to 10
In the middle 4,500 148 to 163 16 151 to 160
In the middle, a row cut in half 4,510 148 to 164 17 151 to 161
At the end 299,700 9,988 to 10,000 13 9,991 to 10,000

In the middle there are 3 extra rows above and 3 below, so 16 or 17 in all. It is 17 when the box’s edges cut a row in half at the top and at the bottom. Then 11 rows show a part of themselves. At the top and the end, there is no room on one side, so 13. The whole page in the result box had 31 tags, not 10,017. And the scroll bar works the same as for the full list. The last row, “Row 10000”, sits at the very bottom.

Overscan: a few extra rows

Why make tags for rows you can’t see? Because the box moves first, and React comes second.

When you scroll, the browser moves the box’s content at once. Then it sends the scroll event. Then your onScroll sets state, and React renders the new rows. For a short moment, the box shows only the tags that were already there.

We measured that moment in Chromium, Firefox and WebKit. We moved the box down, then looked at the bottom edge of the box at once. That was before the scroll event came, so React had not run yet. Then we looked again after React had run.

Moved down by OVERSCAN = 3, before the scroll event OVERSCAN = 0, before the scroll event After React ran (both)
1 row (30 pixels) Row 11 empty Row 11
2 rows Row 12 empty Row 12
3 rows Row 13 empty Row 13
5 rows empty empty Row 15

With no overscan, every scroll shows an empty strip at the edge for a moment. With 3 extra rows, small moves can show the right rows at once. A bigger move still shows an empty strip until React catches up. The web.dev guide on long lists calls it a “flash of empty space”.

So why not overscan 100 rows? Because then you are back to many tags. The same guide says to keep it “as low as possible”. TanStack Virtual and react-window both use 1 extra row if you don’t choose. The web.dev guide’s example uses 4.

Keys: use the item’s id, not its place in the window

Part 8 showed what goes wrong when a key is the index and the list changes. In a windowed list, the list changes on every scroll. Rows leave at the top and join at the bottom.

Here each row has a checkbox. A checkbox is a small box you click to check, so it shows a check mark. The list uses key={i}, the row’s place in visible.

import { useState } from 'react'

const ROW_HEIGHT = 30
const BOX_HEIGHT = 300
const OVERSCAN = 3

const items = Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, text: `Row ${i + 1}` }))

function Row({ text, top }: { text: string; top: number }) {
  return (
    <li style={{ position: 'absolute', top, height: ROW_HEIGHT, left: 24, right: 0 }}>
      <label>
        <input type="checkbox" /> {text}
      </label>
    </li>
  )
}

export default function App() {
  const [scrollTop, setScrollTop] = useState(0)

  const first = Math.floor(scrollTop / ROW_HEIGHT)
  const last = Math.ceil((scrollTop + BOX_HEIGHT) / ROW_HEIGHT)
  const start = Math.max(0, first - OVERSCAN)
  const end = Math.min(items.length, last + OVERSCAN)
  const visible = items.slice(start, end)

  return (
    <div
      onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
      style={{ height: BOX_HEIGHT, overflowY: 'auto', border: '1px solid gray' }}
    >
      <ul style={{ position: 'relative', height: items.length * ROW_HEIGHT, margin: 0, padding: 0 }}>
        {visible.map((item, i) => (
          <Row key={i} text={item.text} top={(start + i) * ROW_HEIGHT} />
        ))}
      </ul>
    </div>
  )
}

Press Run. Check the box next to “Row 5”. Then scroll down slowly, a few rows at a time.

The check mark moves to Row 6, then Row 7. In our test in all three browsers, with the box scrolled to 3,000 pixels, the check mark was on Row 102. Scroll back to the top, and it is on Row 5 again.

Here is why. The checkbox keeps its check mark on the page, in its own tag. Row 5 starts as the fifth tag, with key 4. After you scroll 4 rows down, start becomes 1. Now key 4 belongs to Row 6. React matches the old and new rows by key, so it keeps using that tag, check mark and all. It changes the text to “Row 6” and the top to Row 6’s place.

The keys also cost work, even when no row has a checkbox. We moved the box down by one row in our first windowed list and watched what changed on the page:

  • With key={item.id}, React removed 1 <li> and added 1. The other 15 stayed as they were. Nothing was moved or changed.
  • With key={i}, React added and removed nothing. Instead, it changed the text and the top of all 16 rows.

Rows forget when they leave

Change key={i} to key={item.id} and run it again. Check Row 5 and scroll a little. Now the check mark stays on Row 5. Good.

But scroll far down, then back to the top. The check mark is gone. When Row 5 left the window, React removed its tag, and the check mark went with it. When Row 5 came back, it got a brand new tag.

This is true for anything a row keeps by itself: a check mark, typed text, or its own state. In a windowed list, rows leave the page all the time. So what a row must remember has to live above the list. Part 9 showed how to lift state up. Here App keeps an array of the id numbers of the checked rows:

import { useState } from 'react'

const ROW_HEIGHT = 30
const BOX_HEIGHT = 300
const OVERSCAN = 3

const items = Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, text: `Row ${i + 1}` }))

type RowProps = {
  text: string
  top: number
  checked: boolean
  onToggle: () => void
}

function Row({ text, top, checked, onToggle }: RowProps) {
  return (
    <li style={{ position: 'absolute', top, height: ROW_HEIGHT, left: 24, right: 0 }}>
      <label>
        <input type="checkbox" checked={checked} onChange={onToggle} /> {text}
      </label>
    </li>
  )
}

export default function App() {
  const [scrollTop, setScrollTop] = useState(0)
  const [checked, setChecked] = useState<number[]>([])

  const first = Math.floor(scrollTop / ROW_HEIGHT)
  const last = Math.ceil((scrollTop + BOX_HEIGHT) / ROW_HEIGHT)
  const start = Math.max(0, first - OVERSCAN)
  const end = Math.min(items.length, last + OVERSCAN)
  const visible = items.slice(start, end)

  function toggle(id: number) {
    if (checked.includes(id)) setChecked(checked.filter(x => x !== id))
    else setChecked([...checked, id])
  }

  return (
    <div
      onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
      style={{ height: BOX_HEIGHT, overflowY: 'auto', border: '1px solid gray' }}
    >
      <ul style={{ position: 'relative', height: items.length * ROW_HEIGHT, margin: 0, padding: 0 }}>
        {visible.map((item, i) => (
          <Row
            key={item.id}
            text={item.text}
            top={(start + i) * ROW_HEIGHT}
            checked={checked.includes(item.id)}
            onToggle={() => toggle(item.id)}
          />
        ))}
      </ul>
    </div>
  )
}

Run it, check Row 5, scroll far down and come back. The check mark is still there. Row 5 got a new tag, but App still had its id in checked, so the new checkbox was checked. We checked this in all three browsers. Part 5 showed filter and [...checked, id] for changing an array in state.

What windowing takes away

Rows that are not on the page are not there for the browser either. That changes three things.

Find in page. Browsers can search the page for text, with Ctrl+F or Cmd+F. That search only looks at what is on the page. We searched for “Row 5000” with window.find(), a browser function that searches the page’s text. It found the row in the full list, but not in the windowed list. In all three browsers, the windowed page’s text didn’t contain “Row 5000” at all. If people need to find a row, give them your own search box that searches the array.

Screen readers. A screen reader is a program that reads the page out loud, for people who can’t see the screen. It learns about the page from the browser. In Chromium, the windowed list told it about 13 list items, not 10,000. MDN, a set of web guides, says the browser counts “based only on those present”. To fix the count, MDN says to put two ARIA attributes on each item. ARIA is a set of attributes that tell screen readers more about the page. Part 46 covers it. Here are the two:

  • aria-setsize={items.length}: how many items the whole list has.
  • aria-posinset={start + i + 1}: where this item sits in the whole list, starting at 1.

We could not check what a screen reader says with these. And they fix only the count. A screen reader that reads the page line by line still can’t reach a row that has no tag.

The keyboard. A row with no tag can’t take focus. We tried the Tab key in the checked-rows example in all three browsers, starting on Row 1’s checkbox. With OVERSCAN = 3, 40 presses moved focus down to Row 41. Each time focus reached an extra row below the window, the browser scrolled the box to show it. Then React added the next rows. With OVERSCAN = 0, focus left the list after Row 10. So overscan helps the keyboard too.

Focus can also be lost. We put focus on Row 5’s checkbox, then scrolled the box down 3,000 pixels. Row 5 lost its tag, and focus went to the page’s <body>. It stayed there when we scrolled back. That was the same in all three browsers. Test your list with a keyboard and a screen reader.

Rows of different heights

Our math works because every row is exactly 30 pixels tall. Real rows often aren’t. A chat message can be one line or ten.

Then the math is harder. You can’t know where row 5,000 starts until you know the height of every row before it. And you only know a row’s height after it is on the page and you measure it. Part 14 showed getBoundingClientRect() for measuring a tag.

This is where many apps use a library. Two well-known ones are TanStack Virtual and react-window. TanStack Virtual asks you to guess each row’s size. Then it can measure the real rows on the page. The react-window library takes one height for all rows, or a function that gives each row’s height. It also has a hook for heights that change. Its own guide says those “are not as efficient” as heights you give it. So if you know your rows’ heights, give them.

TanStack Virtual’s first example has the same shape as ours. It has a box, a tall inner list, and rows placed one by one. The playground can’t load these libraries, so this part doesn’t run them.

When you don’t need it

Windowing adds code and takes away find in page. So don’t add it until a list is slow.

  • Try the full list first, and measure. A short list of simple rows may be fast enough. Part 36 shows how to find what is slow.
  • Show the list in pages. Show 50 rows, with Next and Back buttons. Fewer tags, and nothing to measure.
  • Let the browser skip the work. The CSS rule content-visibility: auto lets the browser skip layout and drawing for parts of the page that are off screen. MDN says the skipped content stays on the page. It also stays in the tree that screen readers use. And web.dev says it can still be found with the browser’s search. But every tag still exists, so it doesn’t save React’s work.

Common mistakes

No fixed height on the box

In the windowed list, remove height: BOX_HEIGHT, from the box’s style, so it is only { overflowY: 'auto', border: '1px solid gray' }. Then press Run and try to scroll.

Without a height, the box grows to fit the tall <ul>: 300,000 pixels. So the box never scrolls. The page around it scrolls instead, and the box’s onScroll never runs. We checked in all three browsers: after scrolling the page down 3,000 pixels, the box’s scrollTop was still 0. Only 13 rows had tags, and the middle of the result box was empty.

The fix: give the box a fixed height, and make your math use the same number.

Using the index in the window as the key

key={i} makes React keep using the same tags while the items slide through them. A check mark or typed text stays with the place, not the item. Use key={item.id}, as Part 8 said.

Forgetting overscan

With OVERSCAN = 0, each scroll shows an empty strip at the edge for a moment. Add a few rows on each side. Not many, or you lose the point of windowing.

Heavy rows anyway

Windowing cuts the number of rows. It doesn’t make each row cheaper. And every scroll event sets state. So App and every row in the window render again, even when the same rows stay on the page.

We added console.log('Row', text) to Row in the checked-rows example. Then we scrolled the box 10 pixels at a time, one scroll event each, with Strict Mode on:

  • From 0 to 10 pixels, Row 14 got a tag. It is one of the extra rows below the box. Row logged 28 lines: all 14 rows, twice.
  • From 10 to 20 pixels, no row came or went. Row still logged 28 lines.

Each row rendered twice because of Strict Mode, which Part 2 explained. Keep rows small. If a row is slow, Part 20 showed how memo can skip a row whose props didn’t change. Practice 3 below tries it.

Practice

Use the windowed list examples above.

  1. In the first windowed list, change OVERSCAN to 10. Run it and press the count button. How many rows are on the page at the top?
  2. Add the two aria- attributes from “What windowing takes away” to the <li> in the first windowed list. Run it, and look at the first row’s tag in your browser’s developer tools. What do they say?
  3. In the checked-rows example, add console.log('Row', text) as the first line of Row. Then wrap Row in memo, and import memo from 'react'. Scroll a little. Does memo stop the logs? Why?
  4. Change ROW_HEIGHT to 50 in the first windowed list. The rows use ROW_HEIGHT for their height too, so they get taller as well. How many rows can you see now, and how many does the count button find?
Answers
  1. 20: rows 1 to 10, plus 10 extra below. There is no room above, at the top.
  2. aria-setsize="10000" and aria-posinset="1". The next row says aria-posinset="2".
  3. No. Each scroll event still logs every row in the window, twice, the same as without memo. Near the top that is 28 lines. onToggle={() => toggle(item.id)} makes a new function on every render, so the props are never the same. Part 21 shows the fix: keep one toggle with useCallback, and pass the row its id. toggle uses the updater form of setChecked, c => ..., so it doesn’t need checked and can stay the same function. Here is the whole version:
import { memo, useCallback, useState } from 'react'

const ROW_HEIGHT = 30
const BOX_HEIGHT = 300
const OVERSCAN = 3

const items = Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, text: `Row ${i + 1}` }))

type RowProps = {
  text: string
  top: number
  checked: boolean
  id: number
  onToggle: (id: number) => void
}

const Row = memo(function Row({ id, text, top, checked, onToggle }: RowProps) {
  return (
    <li style={{ position: 'absolute', top, height: ROW_HEIGHT, left: 24, right: 0 }}>
      <label>
        <input type="checkbox" checked={checked} onChange={() => onToggle(id)} /> {text}
      </label>
    </li>
  )
})

export default function App() {
  const [scrollTop, setScrollTop] = useState(0)
  const [checked, setChecked] = useState<number[]>([])

  const first = Math.floor(scrollTop / ROW_HEIGHT)
  const last = Math.ceil((scrollTop + BOX_HEIGHT) / ROW_HEIGHT)
  const start = Math.max(0, first - OVERSCAN)
  const end = Math.min(items.length, last + OVERSCAN)
  const visible = items.slice(start, end)

  const toggle = useCallback((id: number) => {
    setChecked(c => (c.includes(id) ? c.filter(x => x !== id) : [...c, id]))
  }, [])

  return (
    <div
      onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
      style={{ height: BOX_HEIGHT, overflowY: 'auto', border: '1px solid gray' }}
    >
      <ul style={{ position: 'relative', height: items.length * ROW_HEIGHT, margin: 0, padding: 0 }}>
        {visible.map((item, i) => (
          <Row
            key={item.id}
            text={item.text}
            top={(start + i) * ROW_HEIGHT}
            checked={checked.includes(item.id)}
            id={item.id}
            onToggle={toggle}
          />
        ))}
      </ul>
    </div>
  )
}

We tried it with console.log('Row', text) added to Row. Then Row logged 2 lines when one new row came in, both for the new row. It logged nothing when no row came or went. 4. You can see 6 rows, because 300 ÷ 50 = 6. The count button finds 9: the 6 you see, plus 3 extra. The math and the rows both use ROW_HEIGHT, so they still agree.

Interview questions

Try to answer each one out loud before you open the answer.

What is list virtualization, and why does it make a long list faster?

It means putting only the rows that can be seen on the page, plus a few extra. The rest stay as data in the array. A full list of 10,000 rows puts 10,000 tags on the page. Each one costs layout work, memory, and React’s own work. A windowed list keeps about the same small number of tags however long the array is. In our test it had 13 to 17 <li> tags instead of 10,000.

A strong answer adds what it costs. Find in page and screen readers only see the rows that are on the page.

How do you work out which rows to show, for rows of a fixed height?

The first row you can see is Math.floor(scrollTop / rowHeight). The end is Math.ceil((scrollTop + boxHeight) / rowHeight). Add the overscan on each side, and keep both numbers inside the array with Math.max and Math.min. Then slice those rows. Give the inner list a height of rows × rowHeight, so the scroll bar is right. Place each row at index × rowHeight.

What is overscan, and what happens without it?

Overscan is a few extra rows above and below the ones you can see. When you scroll, the browser moves the content first, and React renders the new rows a moment later. Without overscan, that moment shows an empty strip at the edge. With overscan, small scrolls show rows that are already there. Too much overscan brings back the cost you were trying to remove.

Why shouldn’t a windowed list use the index as the key?

The window changes on every scroll, so the index of each item in the window changes too. With key={i}, React keeps using the same tags and changes their text and top. Anything a tag holds, like a check mark or typed text, stays in place while the items slide past it. Use the item’s id. A strong answer adds two points. With the id, a row that leaves the window loses its tag, so its state must live above the list. And react-window uses the row’s index in the whole list as its default key. That index stays with the item while you scroll, so scrolling is fine. It matters when you sort or filter. Its own guide says your own keys are better then, “particularly if your row components are stateful”.

Your windowed list re-renders on every pixel of scrolling. How can you make it do less?

Keep in state only what decides the rows: the index of the first row, not scrollTop. Write setFirst(Math.floor(e.currentTarget.scrollTop / ROW_HEIGHT)). Without scrollTop, work out the end as first + Math.ceil(BOX_HEIGHT / ROW_HEIGHT) + 1. The + 1 covers a row cut in half at the bottom. When the new number is the same as the old one, React skips the render. The useState page warns that React “may still need to call your component” first, in some cases. We tried 30 scroll events of 1 pixel each. Keeping scrollTop in state rendered App 30 times, with Strict Mode off. Keeping the first row’s index rendered it once.

Then keep each row cheap. Use memo with props that stay the same, so rows that stay in the window don’t render again.

When would you not use virtualization?

When you measured the full list and it is fast enough. When pages with Next and Back buttons fit the app. When people need the browser’s find in page. Or when CSS content-visibility: auto is enough, because it skips off-screen layout but keeps every tag on the page. Measure first. Virtualization adds code and takes features away.

Sources

  • Rendering Lists, react.dev: map, keys, and why keys must not change.
  • useState, react.dev: React skips the render when the new state is the same as the old.
  • memo, react.dev: skipping a render when props are the same.
  • Common components, react.dev: the onScroll event.
  • web.dev, How large DOM sizes affect interactivity: why a big page is slower to show and to change, memory, and Lighthouse’s 800 and 1,400 nodes.
  • web.dev, Virtualize large lists with react-window: “windowing”, overscan, “a flash of empty space”, and keeping overscan “as low as possible”.
  • web.dev, content-visibility: off-screen content stays in the page and “can be searched for”.
  • MDN: content-visibility (skips layout and painting; skipped content stays in the accessibility tree), aria-setsize (the browser counts “based only on those present”), aria-posinset, and Element.scrollTop.
  • TanStack Virtual and its Virtualizer API: estimateSize, measureElement and overscan.
  • The react-window README: rowHeight as a number or a function, measured row heights “are not as efficient as predetermined sizes”, overscanCount, and the index as the default key.
  • The counts, the render logs and the key test come from running React 19.3.0 for this post, with Strict Mode on and off. The tag counts, the scrolling, the overscan test, the find test, the Tab key test and the missing height were run in the playground in Chromium, Firefox and WebKit. The list item count is from Chromium’s accessibility tree, and the memory numbers are from Chromium on our computer.
  • This part follows the Virtualization kata in react-katas. The kata places the rows with one translateY on a wrapper. This part gives each row its own top.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.