--- 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'); } }; ```