The Problem
I have an internal tool with an Autocomplete dropdown that loads a large dataset at build time — thousands of entries, grouped alphabetically. Opening the dropdown felt noticeably heavy. Scrolling was janky. The browser was rendering every single list item at once, even the ones you couldn't see.
The fix is virtualization: only render the rows that are actually visible in the viewport, and swap them in and out as you scroll.
Why react-window
react-window is the standard library for this. It's small, well-maintained, and has a VariableSizeList that handles items of different heights — which matters here because group headers are taller than regular options.
MUI's Autocomplete has a ListboxComponent prop specifically for this use case. You swap out the default <ul> with your own component, and that's where react-window lives.
The Non-Obvious Trick
The naive approach — wrapping MUI's rendered <li> elements in a VariableSizeList — doesn't work well. By the time those elements reach ListboxComponent as children, they're already rendered React nodes, making it hard to know which are group headers vs options and what height to assign them.
MUI's docs show a cleaner pattern: make renderOption and renderGroup return raw data instead of React elements, and let ListboxComponent handle all the rendering itself.
1<Autocomplete2 renderOption={(props, option) => [props, option]}3 renderGroup={(params) => params}4 ListboxComponent={MyVirtualizedListbox}5 ...6/>
With this in place, the children that arrive at ListboxComponent are:
- Group objects:
{ key, group, children: [[props, option], ...] } - Each option is a
[props, optionData]tuple
No React elements in sight. The ListboxComponent flattens these into a single array and passes them to VariableSizeList, which renders rows on demand.
Building a Reusable ListboxComponent
I wanted to reuse this across multiple dropdowns with proper TypeScript types for each option shape. A factory function worked well:
1export type OptionRenderer<T> = (2 key: Key,3 optionProps: HTMLAttributes<HTMLLIElement>,4 option: T,5 style: CSSProperties,6) => ReactElement78export function createVirtualizedListbox<T>(renderOption: OptionRenderer<T>) {9 function Row({ data, index, style }: ListChildComponentProps) {10 const dataSet = data[index]11 // adjust for listbox padding12 const inlineStyle = { ...style, top: (style.top as number) + PADDING }1314 if (Object.prototype.hasOwnProperty.call(dataSet, 'group')) {15 return (16 <ListSubheader key={dataSet.key} component="div" style={inlineStyle}>17 {dataSet.group}18 </ListSubheader>19 )20 }2122 const [{ key, ...optionProps }, option] = dataSet as [23 HTMLAttributes<HTMLLIElement> & { key: Key },24 T,25 ]26 return renderOption(key, optionProps, option, inlineStyle)27 }2829 return forwardRef<HTMLDivElement, HTMLAttributes<HTMLElement>>(30 function VirtualizedListbox({ children, ...other }, ref) {31 const itemData: unknown[] = []32 ;(33 children as Array<{ children?: unknown[] } & Record<string, unknown>>34 ).forEach((item) => {35 itemData.push(item)36 itemData.push(...(item.children || []))37 })38 // ... VariableSizeList setup39 },40 )41}
The Row function is defined inside the factory but outside any React component, so it's created once per factory call and stays stable as a VariableSizeList child. The renderOption argument is captured in the closure — no context needed.
Using It
Creating a listbox for a specific option type is a one-liner at module level:
1type Item = { id: number; name: string; group: string }23const ItemListbox = createVirtualizedListbox<Item>(4 (key, optionProps, option, style) => (5 <li key={key} {...optionProps} style={style}>6 {option.name}7 </li>8 ),9)
Then wire it up:
1<Autocomplete2 options={items}3 groupBy={(option) => option.group}4 getOptionLabel={(option) => option.name}5 onChange={(_e, value) => handleChange(value)}6 ListboxComponent={ItemListbox}7 renderOption={(props, option) => [props, option]}8 renderGroup={(params) => params}9 renderInput={...}10/>
One thing worth noting: with this setup, onChange receives the selected option object directly as the second argument. I was previously reading e.target.dataset attributes off the clicked <li> element, which worked for mouse clicks but silently broke keyboard selection (pressing Enter). Switching to the value argument fixed both at once.
The OuterElement Pattern
VariableSizeList needs to attach scroll listeners and a ref to its outer container. MUI's listbox props (including onScroll, aria-* attributes, and the ref) need to land on that same element. The standard pattern is a small context that forwards them:
1const OuterElementContext = createContext<HTMLAttributes<HTMLElement>>({})23const OuterElementType = forwardRef<4 HTMLDivElement,5 HTMLAttributes<HTMLDivElement>6>(function OuterElement(props, ref) {7 const outerProps = useContext(OuterElementContext)8 return <div ref={ref} {...props} {...outerProps} />9})
Inside VirtualizedListbox:
1<OuterElementContext.Provider value={other}>2 <VariableSizeList outerElementType={OuterElementType} innerElementType="ul" ...>3 {Row}4 </VariableSizeList>5</OuterElementContext.Provider>
Without this, keyboard navigation and accessibility break because MUI's event handlers never get attached.
Result
The dropdown opens instantly now. Scrolling through thousands of grouped items is smooth. The DOM at any point has only the visible rows rendered instead of the entire list.
