5.000 elementos en una fila — sin necesidad de virtualización

El consejo habitual con unos cientos de elementos es recurrir a la virtualización. Este ejemplo renderiza 5.000 nodos DOM reales en un solo 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.

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

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.

Performance.source.tsx
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();
  }
}

Ejemplos relacionados

Todos los ejemplos (21)