5 000 éléments dans une rangée — pas besoin de virtualisation
ScrollMenu et reste réactif — l’overflow natif fait le mouvement, un IntersectionObserver fait l’observation, et React ne fait presque rien.5,000 items, no virtualization
Faites glisser le rail ou paginez avec les flèches — chacune des 5 000 cartes est un vrai nœud DOM ; rien n’est fenêtré.
Modifier cet exemple en direct dans Storybook
Là où le travail ne se produit pas
Le défilement n’entre jamais dans React. Le rail est un vrai conteneur overflow : la molette et le toucher le font défiler nativement, et le câblage du glissement n’assigne que scrollContainer.current.scrollLeft — pas d’état, pas de re-rendus par frame. La visibilité est une seule instance d’IntersectionObserver observant les 5 000 éléments ; les callbacks arrivent par lots, et seuls les composants abonnés via useIsVisible se mettent à jour quand leur propre élément bascule. Il n’y a aucun calcul de défilement par élément nulle part.
Ce que la story ajuste
Card est enveloppée dans React.memo avec un comparateur sur selected et title, donc sélectionner une carte ne réconcilie pas les 4 999 autres. La lecture de visibilité passe par useDeferredValue : après un saut de page, des centaines d’éléments basculent d’état d’un coup, et différer garde cette rafale hors du chemin critique de l’interaction qui l’a causée. noPolyfill={true} fait que les défilements programmatiques utilisent le scrollIntoView du navigateur au lieu du polyfill de défilement fluide. Le glissement est le même motif DragDealer que l’exemple mouse-drag.
Le compromis que cette page admet
Le rail de démo ci-dessus n’est pas rendu côté serveur : 5 000 cartes sérialisent en à peu près un mégaoctet de HTML, donc le rail monte côté client uniquement, derrière un espace réservé de même hauteur, et il n’y a pas de décalage de mise en page. C’est la vraie facture à cette taille — le navigateur gère 5 000 nœuds vivants confortablement, mais les expédier comme charge SSR est une décision séparée. Quelque part dans les dizaines de milliers de nœuds, la mémoire et le coût du rendu initial rattrapent aussi ; c’est là que le fenêtrage cesse d’être optionnel.
Notes
- Le DOM des 5 000 cartes est construit une fois, au montage —
React.memotransforme les rendus ultérieurs du parent en no-ops pour chaque carte. - Les flèches paginent à peu près un viewport à la fois, donc traverser tout le rail à la flèche est lent par conception — les flicks de glissement ou les sauts de
scrollToItemconviennent mieux à cette échelle. - Les flèches tournent toujours sur
useIsVisible('first')etuseIsVisible('last')— le même mécanisme d'observateur qu'un menu de dix éléments, à 500 fois le nombre d'éléments.
Source complète
Complète et prête à copier-coller — c’est le fichier exact derrière la version Storybook modifiable en direct.
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();
}
}