# Virtualización y rendimiento

> Cómo virtualiza SuperScheduler filas, celdas y eventos sin renders de React, qué cuesta tiempo en tu integración, una lista para planes grandes y cómo medir.

Source: https://superscheduler.org/es/docs/performance-virtualization/
Reviewed: 2026-10-07

Las dos ediciones virtualizan en dos dimensiones: solo las filas y las fechas cercanas al área visible tienen nodos DOM, y el scroll, el zoom y el arrastre actualizan ese DOM directamente, sin renders de React. En una integración, el tiempo se va en tus hooks de render, en los slots de renderizado React, en la transformación de datos y en las props cuya identidad cambia en cada render. Limita los hooks a búsquedas baratas, mantén estables las props y los objetos de evento, y mide builds de producción con la CPU ralentizada.

SuperScheduler está pensado para planes con miles de filas y cientos de miles de eventos. El motor es un renderizador DOM imperativo: React lo aloja, y tu árbol de React solo se renderiza cuando cambian tus propias props o tu estado. Esta guía explica qué hace el motor por ti, dónde puede seguir gastando tiempo una integración y cómo medir con honestidad.

La virtualización se aplica a las dos ediciones. Lite virtualiza filas, días y eventos con los mismos índices del núcleo y nunca renderiza React durante el scroll. Los hooks de render, el zoom, el nivel de detalle y los slots de renderizado React son funciones de Pro, así que las secciones que tratan de ellos solo se aplican a Pro.

## Cómo funciona la virtualización
### La ventana montada
Solo una ventana alrededor del área visible tiene nodos DOM: el área visible más un margen, ajustada a bloques de un cuarto del área visible, con dos bloques de margen a cada lado. La ventana se recalcula en cada evento de scroll, pero solo cambia cuando se cruza el límite de un bloque. En un fotograma de scroll, el área visible más un bloque se pinta de forma síncrona; el resto de la ventana se pinta de forma progresiva en los fotogramas siguientes, empezando por las filas más cercanas. Las llamadas como `control.update()` renderizan de forma síncrona y nunca se reparten entre fotogramas.

### Filas
Los recursos se aplanan en filas una sola vez (filas del árbol incluidas), y las alturas de fila viven en un índice de sumas de prefijos, así que encontrar las filas de una posición de scroll es una operación logarítmica, no un recorrido por todas las filas. Plegar, desplegar o filtrar reconstruye la lista de filas visibles en una sola pasada. Cuando cambia la altura de una fila, las filas de debajo se desplazan como bloques enteros, en lugar de volver a aplicar estilos a cada celda y cada evento.

### Celdas
Las líneas de la cuadrícula y el sombreado de fines de semana o del tiempo no laborable se pintan como un fondo repetido, así que las celdas normales no tienen ningún nodo DOM. Una celda solo tiene nodo cuando está personalizada (con `onBeforeCellRender` o `renderCell`) y está dentro de la ventana montada. Cuando alejas el zoom por debajo de 2 px por celda, las celdas personalizadas no se crean y su hook no se llama.

### Eventos
Los eventos se indexan por recurso y por tiempo, así que un rango visible se encuentra con una consulta logarítmica. El apilado de solapamientos se calcula a partir de las horas, no de los píxeles, así que el zoom nunca vuelve a apilar las filas. Los nodos de evento salen de un pool y se reutilizan a medida que se mueve la ventana. A tamaños pequeños, el nivel de detalle pasa los eventos de contenido completo a texto, después a bloques simples y, al final, a una barra por fila, y oculta los vínculos cuyos extremos son demasiado pequeños para verse. `lod: false` desactiva esa adaptación y cuesta más con el zoom alejado.

### Sin renders de React durante la interacción
Los fotogramas de scroll, zoom, hover, selección y arrastre los gestiona el motor con geometría en caché y un único planificador de fotogramas que separa las lecturas de layout de las escrituras en el DOM. El contenido React de `super-scheduler/react-render` es la única excepción, por diseño: primero se pinta el HTML o el texto de respaldo, y el contenido React se publica cuando termina la interacción, en lotes que apuntan a 8 ms. Las funciones opcionales, como la navegación con teclado, los menús y las burbujas, se cargan como chunks separados cuando las configuras o las usas por primera vez, nunca durante un gesto.

→ https://superscheduler.org/es/examples/port-berths/
## Qué cuesta tiempo en una integración
La librería no puede abaratar tus callbacks. Estos son los puntos donde las integraciones reales gastan tiempo.

### Hooks de render
| Hook | Cuándo se ejecuta | La caché dura hasta |
|---|---|---|
| `onBeforeEventRender` | Para cada evento que necesita el layout, no solo los visibles, porque puede cambiar `height`, `line` o `hidden` | Un cambio en los datos del evento |
| `onBeforeCellRender` | Para cada celda dentro de la ventana montada, por encima de 2 px por celda | Nuevos `resources`, `control.update()` o un cambio en los eventos de la fila cuando el recurso tiene `cellsAutoUpdated: true` |
| `onBeforeRowHeaderRender` | Para las cabeceras de fila montadas | Un cambio en la fila o en los datos de su recurso |
| `onBeforeTimeHeaderRender` | Para las celdas de cabecera montadas | Un cambio en el eje de tiempo (escala, nivel de zoom, fechas) |
| `onEventMoving`, `onEventResizing`, `onTimeRangeSelecting` | En cada cambio de la sombra durante un gesto | Nunca se cachea |

Pasar una función nueva a un hook de render también vacía su caché. Limita los hooks a búsquedas y construcción de cadenas. Precalcula mapas y conjuntos fuera del hook, crea los formateadores `Intl` una sola vez a nivel de módulo, no leas nunca el layout (`getBoundingClientRect`) y no cambies nunca el estado de React dentro de un hook. Durante un arrastre, `args.conflicts` se calcula al acceder a él por primera vez, así que no lo leas salvo que la regla lo necesite.

### Props cuya identidad cambia
El componente React solo reenvía las props cuya identidad ha cambiado desde el último render (`Object.is`). Cada prop reenviada tiene un coste:

- Un nuevo array `events` cuyos objetos difieren de los del control recarga todo el almacén de eventos y vuelve a ejecutar `onBeforeEventRender` para cada evento. Devolver los mismos objetos (`[...args.events]` desde `onEventsChange`) se reconoce como un eco y se omite la recarga.
- Un nuevo array `resources` reconstruye las filas e invalida todas las celdas.
- Una nueva función de hook invalida la caché de ese hook.
- Los nuevos objetos `timeHeaders`, `zoomLevels`, `classNames` o `styles` se vuelven a aplicar.

Define las constantes a nivel de módulo, memoiza las props derivadas con `useMemo` y envuelve en `useCallback` los handlers que dependen del estado. Una prop que desaparece entre renders vuelve a su valor por defecto, así que mantén estables también las props condicionales.

```tsx
// src/StablePlanning.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type {
  SchedulerBeforeCellRenderArgs,
  SchedulerBeforeEventRenderArgs,
  SchedulerEventsChangeArgs,
} from 'super-scheduler'

type Stay = { status: 'confirmed' | 'tentative'; guests: number }

// Module scope: created once for the life of the page.
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Month' },
  { groupBy: 'Day', format: 'd' },
]
const STATUS_CLASS = {
  confirmed: 'stay stay--confirmed',
  tentative: 'stay stay--tentative',
} as const

// Runs for every event the layout needs, then is cached per event: keep it to lookups and strings.
function onBeforeEventRender(args: SchedulerBeforeEventRenderArgs) {
  const data = args.data as SuperScheduler.EventRenderData<Stay>
  data.cssClass = STATUS_CLASS[data.status]
  data.html = `${SuperScheduler.Util.escapeHtml(data.text)} <small>${data.guests}</small>`
}

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  readonly initial: SuperScheduler.EventData<Stay>[]
  /** Days the hotel is closed, as `yyyy-MM-dd`. */
  readonly closedDays: readonly string[]
}

export function StablePlanning({ rooms, initial, closedDays }: Props) {
  // Map server data to event objects once; new objects on every render would reload the store.
  const [events, setEvents] = useState(initial)
  const owned = useMemo(() => events.slice(), [events])

  // A Set built when its input changes, so the cell hook is a constant-time lookup.
  const closed = useMemo(() => new Set(closedDays), [closedDays])
  const onBeforeCellRender = useCallback(
    (args: SchedulerBeforeCellRenderArgs) => {
      if (closed.has(args.cell.start.toString('yyyy-MM-dd'))) args.cell.properties.disabled = true
    },
    [closed],
  )

  // The same objects handed back are recognized as an echo: no reload, no repaint.
  const onEventsChange = useCallback((args: SchedulerEventsChangeArgs) => {
    setEvents([...args.events] as SuperScheduler.EventData<Stay>[])
  }, [])

  return (
    <SuperSchedulerComponent
      startDate="2026-01-01"
      days={365}
      scale="Day"
      cellWidth={40}
      timeHeaders={TIME_HEADERS}
      resources={rooms}
      events={owned}
      onEventsChange={onEventsChange}
      onBeforeEventRender={onBeforeEventRender}
      onBeforeCellRender={onBeforeCellRender}
    />
  )
}
```
### Slots de renderizado React
`renderEvent`, `renderRowHeader` y las demás props de render montan contenido React mediante portales dentro de nodos del motor. Funcionan bien para eventos y cabeceras. `renderCell` monta un slot React por cada celda montada, lo que se acumula rápido en una cuadrícula densa; en cuadrículas grandes, es preferible usar cadenas de `onBeforeCellRender` y reservar React para las celdas que necesitan interacción. Memoiza las funciones de render, y ajusta `renderOptions.retain` (elementos desmontados que se conservan; por defecto, el menor entre 2.000 y el doble del número de elementos montados) y `renderOptions.sliceMs` (objetivo por lote, 8 ms por defecto) solo después de medir. Consulta [Slots de renderizado React](https://superscheduler.org/es/docs/react-render-slots/).

### Cambios de datos y tu propio estado
- `control.events.add`, `update` y `remove` son incrementales y sirven para ediciones sueltas. Para cientos de cambios a la vez, como una importación o un refresco desde el servidor, entrega al scheduler un array nuevo en lugar de llamarlos en un bucle.
- `control.update()` sin argumentos es un refresco completo. Pasa solo las opciones que han cambiado.
- `onZoom` se ejecuta en cada fotograma de un gesto de zoom. Escribe en el DOM la respuesta visual por fotograma y actualiza el estado de React solo cuando `args.phase === 'end'`.
- `useScheduler({ track: [...] })` de `super-scheduler/hooks` publica cuando los cambios se asientan, nunca por fotograma. Sigue solo los temas que muestra cada componente.
- Los padres plegados del árbol reducen el trabajo de montaje: el layout se calcula para las filas desplegadas.

## Lista de comprobación
- Importa el scheduler en las rutas que lo usan, para que su código quede fuera del resto de tu aplicación ([Renderizado en servidor](https://superscheduler.org/es/docs/ssr-prerender/#client-mount)).
- Mantén estables entre renders `events`, `resources`, `timeHeaders`, `zoomLevels`, `classNames` y los hooks.
- Transforma las filas del servidor en objetos de evento una vez por respuesta, no durante el render.
- Dale al control su propia copia del array de eventos (`useMemo(() => events.slice(), [events])`), porque modifica ese array en el sitio con splice.
- Limita `onBeforeEventRender` y `onBeforeCellRender` a búsquedas; activa `cellsAutoUpdated` solo en las filas cuyas celdas dependen de sus eventos.
- En cuadrículas densas, prefiere hooks que devuelven cadenas a `renderCell`.
- Agrupa los cambios de datos grandes en un único array nuevo.
- Carga las líneas de tiempo largas por rangos con [`super-scheduler/ranges`](https://superscheduler.org/es/docs/range-loading/) en lugar de enviar años de datos.
- Deja `lod` activado salvo que necesites un render literal en todos los niveles de zoom.
- No cambies nunca el estado de React desde callbacks que se ejecutan en cada fotograma.

## Medir
La librería se mide con un método reproducible, y el mismo método sirve para tu integración:

- **Builds de producción.** Los builds de desarrollo de React y de tu aplicación son más lentos y añaden comprobaciones.
- **Fases separadas.** Genera o descarga los datos antes de montar, y después mide el tiempo de montaje con un layout forzado a continuación.
- **Fotogramas, no medias.** Registra los percentiles p50, p95 y p99 del tiempo de fotograma mientras desplazas con la rueda sobre la cuadrícula, en diagonal, durante el autoscroll y con varios anchos de zoom. Cuenta los nodos DOM montados y la memoria después de la recolección de basura.
- **Commits de React.** Envuelve el scheduler en un `<Profiler>` y comprueba que el scroll, el zoom y el arrastre no provocan commits. `onRender` se dispara en los builds de desarrollo; en producción necesita el build de profiling de React.
- **CPU ralentizada.** Repite con la CPU ralentizada 4× en las herramientas de rendimiento del navegador, y en los dispositivos que usan tus usuarios.
- **Aísla tus callbacks.** Compara sin hook, con un hook vacío y con tu hook para ver lo que cuesta tu código.
- **Varias ejecuciones.** Una sola muestra lenta cerca de un umbral es ruido. Compara medianas de varias ejecuciones en una máquina sin otra carga.

`super-scheduler/datasets` genera los mismos escenarios deterministas que usa la librería, así que puedes reproducir una carga sin tu backend:

```tsx
// src/ScrollProfile.tsx
import { Profiler, useRef, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { generateScenario, toSuperSchedulerData } from 'super-scheduler/datasets'

// Deterministic data generated before mounting, so generation is not measured as mount time.
// S2: 1,000 rows over 730 days from 2026-01-01, about 40,000 events. Same seed, same data.
const S2 = toSuperSchedulerData(generateScenario('S2'))
type DatasetResource = (typeof S2.resources)[number]

// The generator's resource type is an interface without an index signature, so it is not directly
// assignable to ResourceData: copy the fields the scheduler needs instead of casting.
const toResource = (resource: DatasetResource): SuperScheduler.ResourceData => ({
  id: resource.id,
  name: resource.name,
  ...(resource.expanded === undefined ? {} : { expanded: resource.expanded }),
  ...(resource.frozen === undefined ? {} : { frozen: resource.frozen }),
  ...(resource.children === undefined ? {} : { children: resource.children.map(toResource) }),
})
const RESOURCES = S2.resources.map(toResource)

export function ScrollProfile() {
  const [events] = useState(() => S2.events.slice())
  const commits = useRef(0)
  const counter = useRef<HTMLOutputElement>(null)

  // Written straight to the DOM: a state update here would itself cause the renders we count.
  const onRender = () => {
    commits.current += 1
    if (counter.current !== null) counter.current.textContent = `${commits.current} React commits`
  }

  return (
    <>
      <output ref={counter}>0 React commits</output>
      <Profiler id="planning" onRender={onRender}>
        <SuperSchedulerComponent
          startDate="2026-01-01"
          days={730}
          scale="Day"
          cellWidth={32}
          treeEnabled
          heightSpec="Fixed"
          height={640}
          resources={RESOURCES}
          events={events}
        />
      </Profiler>
    </>
  )
}
```
Deberías ver que el contador se detiene después del montaje inicial: desplazarte por las 1.000 filas y los dos años del plan no añade ningún commit de React.

### Mediciones publicadas
La revisión de rendimiento de la librería del 7 de octubre de 2026 registró estos resultados. Método: Chromium 145 headless con rasterización por software, área visible de 1440 × 900 con una relación de píxeles del dispositivo de 1, la demo de la librería en un build de producción con el build de profiling de React, en un Apple M5 Pro con 24 GiB que ejecutaba otras aplicaciones. Los tiempos de fotograma son intervalos de `requestAnimationFrame` en una pantalla de unos 120 Hz durante las trazas de scroll del banco de pruebas, así que 8,3 ms es el propio intervalo de fotograma de la pantalla. El montaje de S1 es la mediana de tres montajes; los escenarios más pesados se montaron una vez.

| Escenario | Datos | Montaje | Fotograma p50 / p95 / p99 | Nodos DOM | Heap tras GC |
|---|---|---|---|---|---|
| S1 | 120 filas, 730 días, unos 6.000 eventos | 23,2 ms | 8,3 / 9,1 / 9,3 ms | 2.506 | 11,2 MiB |
| S1, CPU 4× | Los mismos | 104,6 ms | 16,1 / 25,2 / 25,9 ms | 2.506 | 11,2 MiB |
| S3 | 5.000 filas, 1.500 días, 200.021 eventos | 291,6 ms | 8,3 / 9,2 / 9,4 ms | 2.035 | 205,4 MiB |
| S3Dense | Como S3, sin ninguna noche libre en ninguna habitación, 1.634.510 eventos | 2.174,8 ms | 8,3 / 9,3 / 16,8 ms | 3.738 | 1.589,2 MiB |

Todos los escenarios registraron cero renders de React durante el scroll. Estas cifras proceden de una sola máquina en un solo día, con la aplicación de demostración de la propia librería y sus hooks; son observaciones, no garantías para tu integración.

## Límites
El DOM sigue siendo pequeño a cualquier tamaño, pero el tiempo de montaje y la memoria crecen con el número total de eventos, porque al construir una cuadrícula el layout se calcula para todas las filas desplegadas. La fila S3Dense de arriba muestra el techo: más de dos segundos de montaje y unos 1,6 GiB de heap para 1,6 millones de eventos. Mucho antes de llegar ahí, carga por rangos y conserva solo las fechas con las que trabaja la gente. Todavía no hay una API de modificación masiva, así que las actualizaciones en directo muy grandes conviene aplicarlas como un único array de eventos nuevo. Muchos vínculos también añaden trabajo de pintado, ya que cada pintado tiene en cuenta todos los vínculos.

Guías relacionadas: [Integración con React](https://superscheduler.org/es/docs/react-integration/), [Estado controlado](https://superscheduler.org/es/docs/controlled-state/), [Escalas de tiempo y zoom](https://superscheduler.org/es/docs/time-scales-zoom/) y [Solución de problemas](https://superscheduler.org/es/docs/troubleshooting/).
