5.000 elementos en una fila — sin necesidad de virtualización
ScrollMenu y se mantiene responsivo — el overflow nativo hace el movimiento, un IntersectionObserver hace la observación, y React no hace casi nada.5,000 items, no virtualization
Arrastra el carril o pagina con las flechas — cada una de las 5.000 tarjetas es un nodo DOM real; nada está ventaneado.
Edita este ejemplo en vivo en Storybook
Dónde no ocurre el trabajo
El desplazamiento nunca entra en React. El carril es un contenedor overflow genuino: la rueda y el tacto lo desplazan de forma nativa, y el cableado del arrastre solo asigna a scrollContainer.current.scrollLeft — sin estado, sin re-renderizados por frame. La visibilidad es una sola instancia de IntersectionObserver observando los 5.000 elementos; los callbacks llegan en lotes, y solo los componentes que se suscribieron con useIsVisible se actualizan cuando su propio elemento cambia. No hay matemática de desplazamiento por elemento en ningún sitio.
Qué afina la historia
Card se envuelve en React.memo con un comparador sobre selected y title, de modo que seleccionar una tarjeta no reconcilia las otras 4.999. La lectura de visibilidad pasa por useDeferredValue: tras un salto de página, cientos de elementos cambian de estado a la vez, y diferirlo saca ese estallido del camino crítico de la interacción que lo causó. noPolyfill={true} hace que los desplazamientos programáticos usen el scrollIntoView del propio navegador en lugar del polyfill de desplazamiento suave. El arrastre es el mismo patrón DragDealer del ejemplo mouse-drag.
El compromiso que admite esta página
El carril de demo de arriba no se renderiza en el servidor: 5.000 tarjetas serializan a casi un megabyte de HTML, así que el carril se monta solo en el cliente detrás de un marcador de posición de la misma altura y no hay cambio de diseño. Esa es la factura real de este tamaño — el navegador maneja 5.000 nodos vivos con comodidad, pero enviarlos como carga útil de SSR es otra decisión. En algún punto de las decenas de miles de nodos, la memoria y el coste del render inicial también alcanzan; ahí es donde el ventaneo deja de ser opcional.
Notas
- El DOM de las 5.000 tarjetas se construye una vez, al montar —
React.memoconvierte los renders posteriores del padre en no-ops para cada tarjeta. - Las flechas paginan más o menos un viewport a la vez, así que cruzar todo el carril con flechas es lento a propósito — los flicks de arrastre o los saltos de
scrollToItemencajan mejor con esta escala. - Las flechas siguen funcionando con
useIsVisible('first')yuseIsVisible('last')— el mismo mecanismo de observador que un menú de diez elementos, con 500 veces el número de elementos.
Fuente completa
Completa y lista para copiar y pegar: este es el archivo exacto que está detrás de la versión de Storybook editable en vivo.
import 'react-horizontal-scrolling-menu/dist/styles.css';
import styled from '@emotion/styled';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
const ITEMS = 5000;
export function Performance() {
const [items] = React.useState(() => getItems(ITEMS));
const [selected, setSelected] = React.useState<string[]>([]);
// NOTE: for drag by mouse
const dragState = React.useRef(new DragDealer());
const handleDrag = React.useCallback(
({ scrollContainer }: publicApiType) =>
(ev: React.MouseEvent) =>
dragState.current.dragMove(ev, (posDiff) => {
if (scrollContainer.current) {
scrollContainer.current.scrollLeft += posDiff;
}
}),
[],
);
const onMouseDown = React.useCallback(
() => dragState.current.dragStart,
[dragState],
);
const onMouseUp = React.useCallback(
() => dragState.current.dragStop,
[dragState],
);
const handleItemClick = React.useCallback((itemId: string) => {
if (dragState.current.dragging) {
return false;
}
setSelected((currentSelected: string[]) =>
currentSelected.includes(itemId)
? currentSelected.filter((el) => el !== itemId)
: currentSelected.concat(itemId),
);
}, []);
return (
<>
<div style={{ marginBottom: '50px' }}>{ITEMS} items and still fast!</div>
<div onMouseLeave={() => dragState.current.dragStop()}>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={handleDrag}
onWheel={onWheel}
// better for performance
noPolyfill={true}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={handleItemClick}
selected={selected.includes(id)}
/>
))}
</ScrollMenu>
</div>
</>
);
}
export default Performance;
class DragDealer {
clicked: boolean;
dragging: boolean;
position: number;
constructor() {
this.clicked = false;
this.dragging = false;
this.position = 0;
}
public dragStart = (ev: React.MouseEvent) => {
this.position = ev.clientX;
this.clicked = true;
};
public dragStop = () => {
window.requestAnimationFrame(() => {
this.dragging = false;
this.clicked = false;
});
};
public dragMove = (ev: React.MouseEvent, cb: (posDiff: number) => void) => {
const newDiff = this.position - ev.clientX;
const movedEnough = Math.abs(newDiff) > 5;
if (this.clicked && movedEnough) {
this.dragging = true;
}
if (this.dragging && movedEnough) {
this.position = ev.clientX;
cb(newDiff);
}
};
}
const LeftArrow = React.memo(() => {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isFirstItemVisible = visibility.useIsVisible('first', true);
return (
<Arrow
disabled={isFirstItemVisible}
onClick={() => visibility.scrollPrev()}
testId="left-arrow"
>
Left
</Arrow>
);
});
const RightArrow = React.memo(() => {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isLastItemVisible = visibility.useIsVisible('last', false);
return (
<Arrow
disabled={isLastItemVisible}
onClick={() => visibility.scrollNext()}
testId="right-arrow"
>
Right
</Arrow>
);
});
function Arrow({
children,
disabled,
onClick,
testId,
}: {
children: React.ReactNode;
disabled: boolean;
onClick: VoidFunction;
testId: string;
}) {
return (
<ArrowButton disabled={disabled} onClick={onClick} data-testid={testId}>
{children}
</ArrowButton>
);
}
const ArrowButton = styled('button')((props) => ({
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
marginBottom: '2px',
opacity: props.disabled ? '0' : '1',
userSelect: 'none',
borderRadius: '6px',
borderWidth: '1px',
}));
const Card = React.memo(
({
onClick,
selected,
title,
itemId,
}: {
onClick: (itemId: string) => void;
selected: boolean;
title: string;
itemId: string;
}) => {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const isVisible = visibility.useIsVisible(itemId, true);
const isVisibleDeffered = React.useDeferredValue(isVisible);
const handleClick = React.useCallback(
() => onClick(itemId),
[itemId, onClick],
);
const onKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
ev.code === 'Enter' && handleClick();
},
[handleClick],
);
return (
<CardBody
data-cy={itemId}
onClick={handleClick}
onKeyDown={onKeyDown}
data-testid="card"
role="button"
tabIndex={0}
className="card"
visible={isVisibleDeffered}
selected={selected}
>
<div className="header">
<div>{title}</div>
<div className="visible">
visible: {JSON.stringify(isVisibleDeffered)}
</div>
<div className="selected">selected: {JSON.stringify(!!selected)}</div>
</div>
<div className="background" />
</CardBody>
);
},
(prevProps, nextProps) =>
prevProps.selected === nextProps.selected &&
prevProps.title === nextProps.title,
);
const CardBody = styled('div')<{ selected?: boolean; visible?: boolean }>(
(props) => ({
border: '1px solid',
display: 'inline-block',
margin: '0 10px',
width: '160px',
userSelect: 'none',
borderRadius: '8px',
overflow: 'hidden',
'& .header': {
backgroundColor: 'white',
},
'& .visible': {
backgroundColor: props.visible ? 'transparent' : 'gray',
},
'& .background': {
backgroundColor: props.selected ? 'green' : 'bisque',
height: '200px',
},
}),
);
const getId = (index: number) => `${'test'}${index}`;
const getItems = (count: number) =>
Array(count)
.fill(0)
.map((_, ind) => ({ id: getId(ind) }));
function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {
// NOTE: no good standart way to distinguish touchpad scrolling gestures
// but can assume that gesture will affect X axis, mouse scroll only Y axis
// of if deltaY too small probably is it touchpad
const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;
if (isThouchpad) {
ev.stopPropagation();
return;
}
if (ev.deltaY < 0) {
apiObj.scrollNext();
} else {
apiObj.scrollPrev();
}
}