Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/use-virtualizer-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/virtual-core': minor
'@tanstack/react-virtual': minor
---

Add a store interface to the `Virtualizer` and a `useVirtualizerState` hook for React.

- `virtualizer.subscribe(listener)` registers any number of change listeners, and `virtualizer.getState()` returns an immutable `{ virtualItems, totalSize, range, isScrolling, scrollDirection }` snapshot that keeps its identity until a field changes.
- `useVirtualizerState(virtualizer, selector?, isEqual?)` subscribes to that state through `useSyncExternalStore`. Values read through it stay live under the React Compiler, which can otherwise memoise `virtualizer.getVirtualItems()` on the stable instance. It works with both `useVirtualizer` and `useWindowVirtualizer`.
- While a listener is subscribed, an option change that moves the visible range (such as a new `count`) fires `onChange` when it is committed, rather than at the next scroll event.
26 changes: 26 additions & 0 deletions docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,32 @@ Measures the element using your configured `measureElement` virtualizer option.

By default the `measureElement` virtualizer option is configured to measure elements with `getBoundingClientRect()`.

### `subscribe`

```tsx
subscribe: (listener: () => void) => () => void
```

Registers a listener that runs whenever [`getState`](#getstate) returns a new snapshot, and returns an unsubscribe function. That covers every change in the snapshot, including ones that do not fire [`onChange`](#onchange), such as a scroll direction flip within the same range, or a committed `count` change that alters the total size but not the visible range. Unlike `onChange`, any number of listeners can be registered. Listeners receive no `sync` flag; use `onChange` when an update must be flushed synchronously. Pair it with [`getState`](#getstate) to build a store subscription, such as React's `useSyncExternalStore`.

### `getState`

```tsx
getState: () => VirtualizerState

interface VirtualizerState {
virtualItems: VirtualItem[]
totalSize: number
range: { startIndex: number; endIndex: number } | null
isScrolling: boolean
scrollDirection: 'forward' | 'backward' | null
}
```

Returns the render-relevant state of the virtualizer. The returned object keeps its identity until one of its fields changes, so it can be used directly as a store snapshot.

Read it during render, or from a [`subscribe`](#subscribe) listener or [`onChange`](#onchange). Like `getVirtualItems`, it computes the current range and counts it as seen, so a range change that other code reads first, before the virtualizer has notified, does not fire `onChange`.

### `resizeItem`

```tsx
Expand Down
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@
{
"to": "framework/react/examples/window",
"label": "Window"
},
{
"to": "framework/react/examples/react-compiler",
"label": "React Compiler"
}
]
},
Expand Down
61 changes: 61 additions & 0 deletions docs/framework/react/react-virtual.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,67 @@ function useWindowVirtualizer<TItemElement = unknown>(

This function returns a window-based `Virtualizer` instance configured to work with the window as the scrollElement.

## `useVirtualizerState`

```tsx
function useVirtualizerState(
virtualizer: Virtualizer<TScrollElement, TItemElement>,
): VirtualizerState

function useVirtualizerState<TSelected>(
virtualizer: Virtualizer<TScrollElement, TItemElement>,
selector: (state: VirtualizerState) => TSelected,
isEqual?: (a: TSelected, b: TSelected) => boolean,
): TSelected
```

Subscribes to the virtualizer's render-relevant state ([`VirtualizerState`](../../api/virtualizer.md#getstate)) through `useSyncExternalStore`. Works with both `useVirtualizer` and `useWindowVirtualizer`.

Use it for values you read during render (`virtualItems`, `totalSize`, `isScrolling`, …), and keep using the `Virtualizer` instance for imperative calls such as `scrollToIndex`, `measure` or `resizeItem`:

```tsx
const virtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
})
const { virtualItems, totalSize } = useVirtualizerState(virtualizer)

return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: totalSize, position: 'relative' }}>
{virtualItems.map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
width: '100%',
transform: `translateY(${item.start}px)`,
}}
>
Row {item.index}
</div>
))}
</div>
</div>
)
```

Without a selector, the component re-renders whenever any field of the state changes. That includes `isScrolling` and `scrollDirection`, which update while scrolling even when the visible rows stay the same.

Pass a `selector` to re-render only when the selected value changes, such as `virtualItems` for a component that only renders rows. When the selector returns a new object each time, also pass `isEqual`:

```tsx
const isScrolling = useVirtualizerState(virtualizer, (s) => s.isScrolling)
```

### React Compiler

The `Virtualizer` instance is stable across renders, so the [React Compiler](https://react.dev/learn/react-compiler) can memoise reads such as `virtualizer.getVirtualItems()` on it and render stale items. The compiler skips components that call `useVirtualizer` itself, but it still compiles components the virtualizer is passed to, and components that call `useWindowVirtualizer`. Read render values through `useVirtualizerState` there.

## React-Specific Options

### `useFlushSync`
Expand Down
5 changes: 5 additions & 0 deletions examples/react/react-compiler/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
6 changes: 6 additions & 0 deletions examples/react/react-compiler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Example

To run this example:

- `npm install` or `yarn`
- `npm run dev` or `yarn dev`
11 changes: 11 additions & 0 deletions examples/react/react-compiler/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
25 changes: 25 additions & 0 deletions examples/react/react-compiler/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "tanstack-react-virtual-example-react-compiler",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview"
},
"dependencies": {
"@faker-js/faker": "^8.4.1",
"@tanstack/react-virtual": "^3.14.13",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/node": "^24.5.2",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.5.2",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "5.9.3",
"vite": "^6.4.2"
}
}
35 changes: 35 additions & 0 deletions examples/react/react-compiler/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
*,
*:before,
*:after {
box-sizing: border-box;
}

html {
font-family: sans-serif;
font-size: 14px;
}

body {
padding: 1rem;
}

.List {
border: 1px solid #e6e4dc;
max-width: 100%;
}

.ListItemEven {
background-color: #e6e4dc;
}

.Toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}

.Status {
color: #555;
font-variant-numeric: tabular-nums;
}
158 changes: 158 additions & 0 deletions examples/react/react-compiler/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { faker } from '@faker-js/faker'

import { useVirtualizer, useVirtualizerState } from '@tanstack/react-virtual'
import type { ReactVirtualizer, VirtualItem } from '@tanstack/react-virtual'

import './index.css'

type Row = { id: string; text: string }

let nextId = 0
const createRows = (count: number): Array<Row> =>
Array.from({ length: count }, () => ({
id: String(nextId++),
text: faker.lorem.sentence(faker.number.int({ min: 5, max: 60 })),
}))

type ListVirtualizer = ReactVirtualizer<HTMLDivElement, HTMLDivElement>

// This example is built with the React Compiler (see vite.config.js).
//
// The `Virtualizer` instance is stable across renders, so the compiler can
// memoise reads like `virtualizer.getVirtualItems()` on it and render stale
// rows. Values used during render are read through `useVirtualizerState`
// instead, and the instance is kept for imperative calls (`scrollToIndex`,
// `measure`, ...).
function App() {
const parentRef = React.useRef<HTMLDivElement>(null)
const [rows, setRows] = React.useState(() => createRows(10000))

const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 45,
// Stable keys keep each row's measured size when rows are prepended or
// reordered.
getItemKey: React.useCallback((index: number) => rows[index]!.id, [rows]),
// Row positions and the list height are written straight to the DOM, so
// scrolling and re-measuring rows do not re-render React.
directDomUpdates: true,
})

return (
<div>
<div className="Toolbar">
<button onClick={() => virtualizer.scrollToIndex(0)}>
scroll to the top
</button>
<button
onClick={() =>
virtualizer.scrollToIndex(rows.length / 2, { behavior: 'smooth' })
}
>
scroll to the middle
</button>
<button onClick={() => virtualizer.scrollToIndex(rows.length - 1)}>
scroll to the end
</button>
<button onClick={() => setRows((prev) => [...createRows(10), ...prev])}>
prepend 10 rows
</button>
<button
onClick={() => setRows((prev) => faker.helpers.shuffle([...prev]))}
>
shuffle
</button>
</div>
<p className="Status">
<ScrollStatus virtualizer={virtualizer} />
</p>
<div
ref={parentRef}
className="List"
style={{
height: 400,
width: 400,
overflowY: 'auto',
contain: 'strict',
}}
>
<Rows virtualizer={virtualizer} rows={rows} />
</div>
</div>
)
}

// With `directDomUpdates` the virtualizer positions the rows itself, so this
// component only cares about which rows are visible — not where they are.
// Comparing keys and indexes skips re-renders when rows are only re-measured.
const sameRows = (a: Array<VirtualItem>, b: Array<VirtualItem>) =>
a.length === b.length &&
a.every((item, i) => item.key === b[i]!.key && item.index === b[i]!.index)

function Rows({
virtualizer,
rows,
}: {
virtualizer: ListVirtualizer
rows: Array<Row>
}) {
const virtualItems = useVirtualizerState(
virtualizer,
(state) => state.virtualItems,
sameRows,
)

return (
// The virtualizer sets this container's height through `containerRef`.
<div
ref={virtualizer.containerRef}
style={{ width: '100%', position: 'relative' }}
>
{virtualItems.map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
className={item.index % 2 ? 'ListItemOdd' : 'ListItemEven'}
// Anchored at the top; the virtualizer writes `transform`.
style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}
>
<div style={{ padding: '10px 0' }}>
<div>
Row {item.index} <small>(id {rows[item.index]?.id})</small>
</div>
<div>{rows[item.index]?.text}</div>
</div>
</div>
))}
</div>
)
}

// Selectors re-render only when the selected value changes: this component
// ignores size changes and only follows the visible range and scrolling.
function ScrollStatus({ virtualizer }: { virtualizer: ListVirtualizer }) {
const range = useVirtualizerState(virtualizer, (state) => state.range)
const isScrolling = useVirtualizerState(
virtualizer,
(state) => state.isScrolling,
)

return (
<>
Visible rows {range ? `${range.startIndex}–${range.endIndex}` : '–'}
{isScrolling ? ' · scrolling…' : ''}
</>
)
}

const container = document.getElementById('root')!
const root = createRoot(container)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
25 changes: 25 additions & 0 deletions examples/react/react-compiler/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"composite": true,
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

/* Bundler mode */
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",

/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
Loading
Loading