---
name: virtualization
description: MUI list virtualization with react-window, react-virtuoso, virtualized Autocomplete, and large dataset rendering patterns
triggers:
- virtualization
- virtual list
- react-window
- react-virtuoso
- large list
- infinite scroll
- windowing
allowed-tools:
- Read
- Glob
- Grep
- Write
- Edit
globs:
- "*.tsx"
- "*.ts"
---
# MUI Virtualization
## Why Virtualize
Rendering 1000+ DOM nodes causes:
- **Layout thrashing** — the browser recalculates layout for every node on scroll
- **Memory pressure** — each MUI ListItem creates 3-5 DOM elements (ripple, text, icon wrappers)
- **Initial paint delay** — 10,000 items can take 2-4 seconds to mount
The fix: render only the visible items plus a small overscan buffer. Libraries like
react-window and react-virtuoso handle the math; you supply the row renderer.
**Rule of thumb:** virtualize any list with more than 100 items, or any list where
item count is unbounded (API-driven).
---
## react-window with MUI
Install: `npm install react-window @types/react-window`
### FixedSizeList with MUI ListItem
Use when every row has the same pixel height.
```tsx
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
interface User {
id: string;
name: string;
email: string;
avatarUrl: string;
}
interface VirtualUserListProps {
users: User[];
height?: number;
}
function renderRow(users: User[]) {
return function Row({ index, style }: ListChildComponentProps) {
const user = users[index];
return (
issues
disablePadding
sx={{ borderBottom: 1, borderColor: 'divider' }}
>
);
};
}
export function VirtualUserList({ users, height = 400 }: VirtualUserListProps) {
return (
{renderRow(users)}
);
}
```
### VariableSizeList with MUI ListItem
Use when rows have different heights (e.g., multi-line text, expandable content).
```tsx
import { VariableSizeList } from 'react-window';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import { useRef, useCallback } from 'react';
interface Message {
id: string;
sender: string;
body: string;
}
interface VirtualMessageListProps {
messages: Message[];
height?: number;
}
// Estimate height based on text length; refine with measureRef if needed
function getItemSize(messages: Message[]) {
return (index: number): number => {
const body = messages[index].body;
if (body.length < 80) return 56;
if (body.length < 200) return 80;
return 120;
};
}
export function VirtualMessageList({ messages, height = 500 }: VirtualMessageListProps) {
const listRef = useRef(null);
// Call this after data changes to recalculate sizes
const resetSizes = useCallback(() => {
listRef.current?.resetAfterIndex(0, true);
}, []);
return (
{({ index, style }) => {
const msg = messages[index];
return (
);
}}
);
}
```
**Key gotcha:** When data changes, call `listRef.current.resetAfterIndex(0, true)` to
force VariableSizeList to recalculate cached sizes.
---
## react-virtuoso with MUI
Install: `npm install react-virtuoso`
Virtuoso handles variable-height items automatically (no `itemSize` function needed)
and supports grouped lists, headers, footers, and scroll-to-index out of the box.
### Basic Virtuoso with MUI List Components
```tsx
import { Virtuoso } from 'react-virtuoso';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import FolderIcon from '@mui/icons-material/Folder';
import { forwardRef, type ReactElement } from 'react';
interface FileItem {
id: string;
name: string;
size: string;
onClick: () => void;
}
// Virtuoso needs the list container and item wrappers as custom components
const MuiListComponent = forwardRef>(
(props, ref) =>
,
);
MuiListComponent.displayName = 'MuiListComponent';
const MuiItemComponent = forwardRef>(
(props, ref) => ,
);
MuiItemComponent.displayName = 'MuiItemComponent';
interface VirtualFileListProps {
files: FileItem[];
height?: number;
}
export function VirtualFileList({ files, height = 600 }: VirtualFileListProps): ReactElement {
return (
{
const file = files[index];
return (
);
}}
/>
);
}
```
### Grouped Virtuoso with MUI Subheaders
```tsx
import { GroupedVirtuoso } from 'react-virtuoso';
import ListSubheader from '@mui/material/ListSubheader';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
interface GroupedData {
groups: string[];
groupCounts: number[];
items: Array<{ label: string; value: string }>;
}
export function GroupedVirtualList({ groups, groupCounts, items }: GroupedData) {
return (
(
{groups[index]}
)}
itemContent={(index) => (
)}
/>
);
}
```
---
## Virtualized Autocomplete
MUI's official pattern for Autocomplete with thousands of options. The key is
overriding the `ListboxComponent` prop with a virtualized list.
### Using react-window
```tsx
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Popper from '@mui/material/Popper';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import {
forwardRef,
useRef,
createContext,
useContext,
type HTMLAttributes,
type ReactElement,
} from 'react';
// Context to pass props from Autocomplete to the virtualized list
const OuterElementContext = createContext>({});
const OuterElementType = forwardRef>(
(props, ref) => {
const outerProps = useContext(OuterElementContext);
return ;
},
);
OuterElementType.displayName = 'OuterElementType';
function renderRow({ data, index, style }: ListChildComponentProps) {
const [props, option] = data[index];
return (
{option}
);
}
// The ListboxComponent override — this is the core pattern
const ListboxComponent = forwardRef>(
function ListboxComponent(props, ref) {
const { children, ...other } = props;
const itemData = children as [HTMLAttributes, string][];
const itemCount = itemData.length;
const itemSize = 48;
const getHeight = () => Math.min(8 * itemSize, itemCount * itemSize);
return (
{renderRow}
);
},
);
// Custom Popper that disables the Flip modifier for stable positioning
const StyledPopper = (props: React.ComponentProps) => (
);
interface VirtualizedAutocompleteProps {
options: string[];
label?: string;
}
export function VirtualizedAutocomplete({
options,
label = 'Search',
}: VirtualizedAutocompleteProps): ReactElement {
return (
}
renderOption={(props, option, state) => [props, option, state.index] as React.ReactNode}
/>
);
}
```
### Using useAutocomplete (headless)
For full control, use the headless hook from `@mui/base`:
```tsx
import { useAutocomplete } from '@mui/base/useAutocomplete';
import { Virtuoso } from 'react-virtuoso';
import Box from '@mui/material/Box';
import InputBase from '@mui/material/InputBase';
import Paper from '@mui/material/Paper';
interface Option {
id: string;
label: string;
}
export function HeadlessVirtualAutocomplete({ options }: { options: Option[] }) {
const {
getRootProps,
getInputProps,
getListboxProps,
getOptionProps,
groupedOptions,
focused,
} = useAutocomplete({
options,
getOptionLabel: (opt) => opt.label,
isOptionEqualToValue: (opt, val) => opt.id === val.id,
});
return (
{groupedOptions.length > 0 && (
{
const option = groupedOptions[index] as Option;
const optionProps = getOptionProps({ option, index });
return (
{option.label}
);
}}
/>
)}
);
}
```
---
## DataGrid Virtualization
MUI DataGrid virtualizes rows and columns by default. No extra library needed.
### Tuning Buffer Sizes
```tsx
import { DataGrid, type GridColDef } from '@mui/x-data-grid';
const columns: GridColDef[] = [
{ field: 'id', headerName: 'ID', width: 90 },
{ field: 'name', headerName: 'Name', width: 200 },
{ field: 'email', headerName: 'Email', width: 250, flex: 1 },
{ field: 'status', headerName: 'Status', width: 120 },
];
interface LargeDataGridProps {
rows: Array<{ id: number; name: string; email: string; status: string }>;
}
export function LargeDataGrid({ rows }: LargeDataGridProps) {
return (
row.id}
// Pagination alternative: use if dataset is truly massive (100k+)
// paginationModel={{ pageSize: 100, page: 0 }}
// pageSizeOptions={[50, 100, 250]}
sx={{ height: 600 }}
/>
);
}
```
### Server-Side Virtualization with DataGridPro
For datasets too large to load entirely into the client:
```tsx
import { DataGridPro, type GridColDef } from '@mui/x-data-grid-pro';
import { useCallback, useRef, useState } from 'react';
interface ServerRow {
id: number;
[key: string]: unknown;
}
export function ServerVirtualGrid({ columns }: { columns: GridColDef[] }) {
const [rows, setRows] = useState([]);
const [rowCount, setRowCount] = useState(0);
const [loading, setLoading] = useState(false);
const loadedPages = useRef(new Set());
const handleFetchRows = useCallback(async (params: { firstRowToRender: number; lastRowToRender: number }) => {
const pageSize = 100;
const page = Math.floor(params.firstRowToRender / pageSize);
if (loadedPages.current.has(page)) return;
loadedPages.current.add(page);
setLoading(true);
const response = await fetch(`/api/data?page=${page}&size=${pageSize}`);
const data = await response.json();
setRows((prev) => {
const next = [...prev];
data.items.forEach((item: ServerRow, i: number) => {
next[page * pageSize + i] = item;
});
return next;
});
setRowCount(data.totalCount);
setLoading(false);
}, []);
return (
row.id}
sx={{ height: 700 }}
/>
);
}
```
---
## Infinite Scrolling
### react-virtuoso with API Pagination
```tsx
import { Virtuoso } from 'react-virtuoso';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
import { useState, useCallback, forwardRef } from 'react';
interface Post {
id: number;
title: string;
excerpt: string;
}
const MuiList = forwardRef>(
(props, ref) =>
,
);
MuiList.displayName = 'MuiList';
export function InfinitePostList() {
const [posts, setPosts] = useState([]);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const cursor = posts.length;
const response = await fetch(`/api/posts?offset=${cursor}&limit=50`);
const data: { items: Post[]; hasMore: boolean } = await response.json();
setPosts((prev) => [...prev, ...data.items]);
setHasMore(data.hasMore);
setLoading(false);
}, [posts.length, loading, hasMore]);
return (
loading ? (
) : null,
}}
itemContent={(index, post) => (
)}
/>
);
}
```
### IntersectionObserver Pattern (No Library)
For simpler cases where you just need "load more when sentinel enters viewport":
```tsx
import { useEffect, useRef, useCallback, useState } from 'react';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
function useInfiniteScroll(onLoadMore: () => Promise, hasMore: boolean) {
const sentinelRef = useRef(null);
const loadingRef = useRef(false);
const handleIntersect = useCallback(
async (entries: IntersectionObserverEntry[]) => {
if (entries[0].isIntersecting && hasMore && !loadingRef.current) {
loadingRef.current = true;
await onLoadMore();
loadingRef.current = false;
}
},
[onLoadMore, hasMore],
);
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect, {
rootMargin: '200px', // trigger 200px before sentinel is visible
});
const sentinel = sentinelRef.current;
if (sentinel) observer.observe(sentinel);
return () => {
if (sentinel) observer.unobserve(sentinel);
};
}, [handleIntersect]);
return sentinelRef;
}
interface Item {
id: string;
title: string;
}
export function InfiniteScrollList() {
const [items, setItems] = useState- ([]);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
const res = await fetch(`/api/items?offset=${items.length}&limit=30`);
const data = await res.json();
setItems((prev) => [...prev, ...data.items]);
setHasMore(data.hasMore);
}, [items.length]);
const sentinelRef = useInfiniteScroll(loadMore, hasMore);
return (
{items.map((item) => (
))}
{!hasMore && (
No more items
)}
);
}
```
---
## Table Virtualization
### react-window with MUI Table
```tsx
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import { forwardRef, type ReactElement } from 'react';
interface DataRow {
id: number;
name: string;
department: string;
salary: number;
}
interface VirtualTableProps {
rows: DataRow[];
height?: number;
}
// The inner element must be a for valid HTML
const InnerElement = forwardRef>(
({ children, ...rest }, ref) => (
{children}
),
);
InnerElement.displayName = 'InnerElement';
function Row({ index, style, data }: ListChildComponentProps) {
const row = data[index];
return (
{row.id}
{row.name}
{row.department}
${row.salary.toLocaleString()}
);
}
export function VirtualTable({ rows, height = 500 }: VirtualTableProps): ReactElement {
return (
ID
Name
Department
Salary
{Row}
);
}
```
---
## Performance Tips
### 1. Memoize Row Renderers
Row renderers re-execute on every scroll tick. Memoize expensive computation:
```tsx
import { memo } from 'react';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
interface RowProps {
item: { id: string; name: string; description: string };
style: React.CSSProperties;
}
export const MemoizedRow = memo(function MemoizedRow({ item, style }: RowProps) {
return (
);
});
```
### 2. Stable Row Heights
Avoid content that changes height after render (images loading, text wrapping
differently). Unstable heights cause:
- Scroll jumping in VariableSizeList
- Incorrect item positioning
- Need for repeated `resetAfterIndex` calls
```tsx
// GOOD — fixed height, text clamped
// GOOD — explicit min/max height
```
### 3. Overscan Counts
| Scenario | Recommended Overscan |
|----------|---------------------|
| Fast scrolling, simple rows | 3-5 |
| Slow scrolling, complex rows | 5-10 |
| Keyboard navigation | 1-2 (lower = faster focus) |
| Accessibility (screen readers) | 10+ (more content in DOM) |
### 4. Avoid Re-Creating itemData on Every Render
```tsx
// BAD — new array reference every render, all rows re-render
// GOOD — stable reference
const itemData = useMemo(() => items.map(transform), [items]);
```
### 5. Use layout="horizontal" for Horizontal Lists
```tsx
{renderCard}
```
### 6. Keyboard Accessibility in Virtual Lists
Virtual lists can break keyboard navigation because off-screen items are not in the
DOM. Use `scrollToIndex` on arrow key events:
```tsx
const listRef = useRef(null);
const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setFocusedIndex((prev) => Math.min(prev + 1, items.length - 1));
listRef.current?.scrollToItem(focusedIndex + 1, 'smart');
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setFocusedIndex((prev) => Math.max(prev - 1, 0));
listRef.current?.scrollToItem(focusedIndex - 1, 'smart');
}
};
```