diff --git a/.changeset/direct-dom-no-container.md b/.changeset/direct-dom-no-container.md
new file mode 100644
index 00000000..3e062501
--- /dev/null
+++ b/.changeset/direct-dom-no-container.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/react-virtual': patch
+---
+
+Make `directDomUpdates` a no-op for direct DOM writes when `containerRef` is omitted. Previously the virtualizer still wrote item positions while never sizing the container (a broken half-state). Now omitting `containerRef` skips all direct writes while still skipping re-renders, letting consumers own the DOM updates themselves (e.g. in `onChange`).
diff --git a/docs/framework/react/react-virtual.md b/docs/framework/react/react-virtual.md
index 0ef6bd8b..02d544e0 100644
--- a/docs/framework/react/react-virtual.md
+++ b/docs/framework/react/react-virtual.md
@@ -89,6 +89,8 @@ const virtualizer = useVirtualizer({
> ⚠️ This flag is intended to be set once at mount. Toggling it (or `directDomUpdatesMode`) at runtime can leave stale inline styles on items and the container.
+> **Note:** If you omit `containerRef`, the virtualizer makes no direct DOM writes — it writes neither item positions nor the container size. You're then responsible for positioning items and sizing the container yourself (e.g. in `onChange`), while still benefiting from the skipped re-renders.
+
#### Example
```tsx
diff --git a/packages/react-virtual/e2e/app/direct-dom-updates/main.tsx b/packages/react-virtual/e2e/app/direct-dom-updates/main.tsx
index e40673d1..79c8b84a 100644
--- a/packages/react-virtual/e2e/app/direct-dom-updates/main.tsx
+++ b/packages/react-virtual/e2e/app/direct-dom-updates/main.tsx
@@ -10,6 +10,10 @@ const App = () => {
const params = new URLSearchParams(window.location.search)
const mode = (params.get('mode') ?? 'transform') as 'position' | 'transform'
+ // When set, the consumer omits `containerRef`. The virtualizer must then make
+ // no direct DOM writes at all (neither item positions nor container size),
+ // leaving the consumer to own them — while still skipping re-renders.
+ const noContainer = params.get('noContainer') === '1'
const renderCount = React.useRef(0)
renderCount.current += 1
@@ -40,7 +44,7 @@ const App = () => {
style={{ height: 400, overflow: 'auto' }}
>
diff --git a/packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts b/packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts
index c20c7050..71b7f8dc 100644
--- a/packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts
+++ b/packages/react-virtual/e2e/app/test/direct-dom-updates.spec.ts
@@ -89,5 +89,41 @@ for (const mode of ['position', 'transform'] as const) {
expect(style).toMatch(/translate3d\(0px,\s*20000px,\s*0px\)/)
}
})
+
+ test('without containerRef the virtualizer makes no direct DOM writes but still skips re-renders', async ({
+ page,
+ }) => {
+ await page.goto(`/direct-dom-updates/?mode=${mode}&noContainer=1`)
+
+ // Container size is NOT written by the virtualizer when containerRef is omitted.
+ const inner = page.locator('#inner')
+ const innerStyle = (await inner.getAttribute('style')) ?? ''
+ expect(innerStyle).not.toMatch(/height:\s*\d/)
+
+ // Item positions are NOT written by the virtualizer either.
+ const first = page.locator('[data-testid="item-0"]')
+ await expect(first).toBeVisible()
+ const firstStyle = (await first.getAttribute('style')) ?? ''
+ if (mode === 'position') {
+ expect(firstStyle).not.toMatch(/top:\s*\d/)
+ } else {
+ expect(firstStyle).not.toMatch(/transform:/)
+ }
+
+ // Re-render skipping still applies: scrolling by one item (absorbed by
+ // overscan) must not trigger React re-renders.
+ const initialRenders = Number(
+ await page.locator('[data-testid="render-count"]').textContent(),
+ )
+ await page.locator('#scroll-container').evaluate((el, by) => {
+ el.scrollTop = by
+ }, ITEM_SIZE)
+ // Give onChange a chance to fire.
+ await page.waitForTimeout(50)
+ const afterRenders = Number(
+ await page.locator('[data-testid="render-count"]').textContent(),
+ )
+ expect(afterRenders - initialRenders).toBeLessThanOrEqual(2)
+ })
})
}
diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx
index a78b948f..a78fbdf6 100644
--- a/packages/react-virtual/src/index.tsx
+++ b/packages/react-virtual/src/index.tsx
@@ -111,10 +111,10 @@ function useVirtualizerBase<
instance: Virtualizer,
) => {
const state = directRef.current
- if (!state.enabled) return
+ if (!state.enabled || !state.container) return
const totalSize = instance.getTotalSize()
- if (state.container && totalSize !== state.lastSize) {
+ if (totalSize !== state.lastSize) {
state.lastSize = totalSize
const sizeAxis = instance.options.horizontal ? 'width' : 'height'
state.container.style[sizeAxis] = `${totalSize}px`