# Eventos controlados y callbacks

> Mantén los eventos en estado React con onEventsChange, dale al control su copia, conoce el orden de callbacks al soltar y guarda de forma optimista o tras confirmar.

Source: https://superscheduler.org/es/docs/controlled-state/
Reviewed: 2026-10-07

Pasa los eventos desde el estado de React y escríbelos de vuelta en onEventsChange, que el control llama una vez por tarea después de soltar, redimensionar o una llamada a control.events, con la lista nueva, los objetos cambiados y eliminados, y un motivo. Da al control una copia de tu array, porque el control lo adopta y lo edita en el sitio. La librería nunca habla con tu backend: guarda desde onEventMove para confirmar antes de que el cambio se aplique, o desde onEventsChange para guardar de forma optimista y revertir si falla.

SuperScheduler Pro admite dos formas de ser dueño de los datos de eventos. **Controlado**: el estado de React es la fuente de verdad, lo pasas como `events` y `onEventsChange` te dice qué han cambiado el usuario o la API. **No controlado**: entregas los datos iniciales con `defaultEvents` y el control mantiene su propia lista. El modo controlado es la opción por defecto adecuada para una aplicación que guarda los cambios, los muestra en otra parte de la página o permite deshacer.

Esta página explica el patrón controlado, qué recibe el callback de cambios, las reglas de propiedad del array que lo hacen funcionar, el orden exacto de los callbacks al soltar un evento y dónde entra tu backend.

## El patrón controlado
```tsx
// src/ControlledPlanning.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs, SuperScheduler } from 'super-scheduler'

const ROOMS: SuperScheduler.ResourceData[] = [
  { id: 'r101', name: 'Room 101' },
  { id: 'r102', name: 'Room 102' },
]
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Month' },
  { groupBy: 'Day', format: 'd' },
]

const INITIAL: SuperScheduler.EventData[] = [
  {
    id: 'b-1042',
    resource: 'r101',
    start: '2026-10-02T14:00:00',
    end: '2026-10-05T11:00:00',
    text: 'Booking 1042',
  },
  {
    id: 'b-1043',
    resource: 'r102',
    start: '2026-10-03T14:00:00',
    end: '2026-10-08T11:00:00',
    text: 'Booking 1043',
  },
]

export function ControlledPlanning() {
  // React state is the single source of truth for the events.
  const [events, setEvents] = useState<SuperScheduler.EventData[]>(INITIAL)

  // The control adopts the array it receives and splices it in place: give it its own copy.
  const owned = useMemo(() => events.slice(), [events])

  // Once per task, after a drop, a resize or a control.events call. Handing the same objects back
  // is recognised as an echo: the control does not reload or repaint.
  const onEventsChange = useCallback((args: SchedulerEventsChangeArgs) => {
    setEvents([...args.events])
  }, [])

  // Changes made outside the scheduler go to state; the control picks up the new array.
  const addBlock = () =>
    setEvents((current) => [
      ...current,
      {
        id: `block-${crypto.randomUUID()}`,
        resource: 'r102',
        start: '2026-10-12T00:00:00',
        end: '2026-10-14T00:00:00',
        text: 'Maintenance',
        moveDisabled: true,
        resizeDisabled: true,
      },
    ])

  return (
    <>
      <p>
        {events.length} events{' '}
        <button type="button" onClick={addBlock}>
          Block Room 102
        </button>
      </p>
      <SuperSchedulerComponent
        startDate="2026-10-01"
        days={31}
        scale="Day"
        timeHeaders={TIME_HEADERS}
        resources={ROOMS}
        events={owned}
        onEventsChange={onEventsChange}
      />
    </>
  )
}
```
Deberías ver dos reservas y un contador de eventos. Arrastra una reserva a la otra habitación: se queda donde la sueltas, porque su nueva posición ya está en el estado de React. Pulsa el botón: el contador sube y aparece un bloque de mantenimiento en Room 102, bloqueado para que no se pueda mover ni redimensionar.

Tres líneas sostienen el patrón:

1. `useState` guarda los eventos. Todo lo que los muestra o los edita lee este estado.
2. `useMemo(() => events.slice(), [events])` da al control **su propia copia** del array (consulta [propiedad del array](#ownership)).
3. `onEventsChange` escribe la lista nueva del control de vuelta en el estado con `setEvents([...args.events])`.

Cuando el estado cambia por otro motivo (un formulario, un push del servidor, el botón de arriba), el array nuevo llega al control como una prop cambiada y el control lo recarga.

## Qué recibe onEventsChange
`onEventsChange` se llama después de que cambie el almacén de eventos del control, **como mucho una vez por tarea**: varios cambios hechos en el mismo bloque síncrono llegan juntos en una sola llamada, en la siguiente microtarea.

| Argumento | Contenido |
|---|---|
| `events` | La lista completa del control después del cambio, como objetos de datos |
| `changed` | Los objetos añadidos o sustituidos en este cambio, en su nuevo estado |
| `removed` | Los objetos eliminados o sustituidos en este cambio, en su estado anterior |
| `reason` | Por qué cambió el almacén (abajo) |

| `reason` | Lo provoca |
|---|---|
| `'move'` | Un arrastrar y soltar confirmado, también con el teclado, y lo que se suelta desde fuera del scheduler |
| `'resize'` | Un redimensionado confirmado |
| `'create'` | `control.events.add()` |
| `'update'` | `control.events.update()` con un objeto nuevo, o un alta y una baja en la misma tarea |
| `'remove'` | `control.events.remove()`, incluido el botón de borrado integrado (`eventDeleteHandling: 'Update'`) |
| `'history'` | Deshacer o rehacer aplicado por el control mediante `super-scheduler/history` |
| `'load'` | Pasaste objetos de evento distintos como `events`, o un cargador de rangos fusionó eventos recién cargados |
| `'api'` | Otros cambios que la librería hace en el almacén por su cuenta; trátalos como `'update'` |

En un movimiento, `changed` contiene el objeto nuevo y `removed` el objeto al que sustituyó: tienes el estado anterior y el posterior sin guardar tu propia copia. Los objetos de `events` conservan su identidad entre llamadas salvo que hayan cambiado, así que `React.memo` y los selectores que comparan por referencia siguen funcionando.

## No controlado: defaultEvents
Pasa `defaultEvents` en lugar de `events` cuando el scheduler pueda ser el dueño de los datos, por ejemplo en una vista casi de solo lectura o en un prototipo. El array se lee una sola vez, durante la inicialización; los cambios posteriores de la prop se ignoran, con un aviso en desarrollo. Si pasas ambas, gana `events`, también con un aviso.

En este modo, lee los datos actuales de `control.events.list`, suscríbete con `useScheduler({ track: ['events'] })` de `super-scheduler/hooks` o sigue escuchando `onEventsChange`, que funciona en ambos modos.

## El control adopta tu array
Por rendimiento, el control no copia el array que pasas como `events`: `control.events.list` **es** ese array, y las altas, las bajas y las operaciones de soltar lo editan en el sitio con `splice`. Por eso el patrón de arriba pasa una copia. Sin ella, el control mutaría el array que hay dentro de tu estado de React, a espaldas de React.

La misma regla explica los demás comportamientos del bucle controlado:

- **Los ecos no cuestan nada.** Cuando `onEventsChange` guarda `[...args.events]`, React renderiza y el control recibe un array que contiene exactamente los objetos que ya tiene. Reconoce el eco y no hace nada: ni recarga ni repinta.
- **Los objetos nuevos provocan una recarga.** Cuando tu estado contiene objetos que el control no ha visto (una edición en un formulario, una respuesta del servidor), recarga su lista desde el array nuevo y después informa con `reason: 'load'`. Volver a guardar esa lista es un eco, así que el bucle termina ahí.
- **No congeles el array** si llamas a `control.events.add`, `update` o `remove`: lo editan en el sitio y lanzan un `TypeError` sobre un array congelado. Al soltar y al redimensionar, primero se copia el array congelado.

> **Behavior:**
> Soltar un evento nunca edita tu objeto: la librería lo sustituye por un objeto nuevo, `{ ...old, start, end, resource }`, cuyos `start` y `end` son valores `SuperScheduler.Date`. Los setters del envoltorio del evento (`e.start(value)`, `e.end(value)`) son distintos: escriben en el objeto de datos existente. En modo controlado, usa preferentemente `control.events.update({ ...e.data, end })` con un objeto nuevo, o cambia tu estado directamente.

## Orden de los callbacks al soltar
Cada arrastrar y soltar sigue una secuencia fija. Este logger la muestra:

```ts
// src/tracing.ts
import type { SchedulerProps } from 'super-scheduler'

// Logs every callback of one drag-and-drop, in the order the library calls them.
export const tracing: SchedulerProps = {
  onEventMoving: (args) =>
    console.debug('1. moving (every shadow change)', args.start.value, args.allowed),
  onEventMove: (args) =>
    console.debug('2. move (before the commit, cancelable)', args.newStart.value),
  onEventMoved: (args) =>
    // The store already holds the new times here.
    console.debug(
      '3. moved (after the commit)',
      args.control.events.find(args.e.id())?.start().value,
    ),
  onEventsChange: (args) =>
    console.debug('4. eventsChange (next microtask)', args.reason, args.changed.length),
}
```
1. **`onEventMoving`** se ejecuta en cada cambio de la sombra mientras el usuario arrastra. Puede rechazar la posición o ajustarla (consulta [Arrastrar, redimensionar y reglas de negocio](https://superscheduler.org/es/docs/drag-resize-rules/)).
2. Al soltar, si la última posición fue rechazada (por tu regla, por un solape, por una celda deshabilitada), **no se ejecuta nada más**: ni `onEventMove` ni cambio alguno.
3. **`onEventMove`** se ejecuta una vez, antes de que cambie el almacén. Puede cancelar con `args.preventDefault()`, cambiar `args.newStart`, `args.newEnd` o `args.newResource`, o aplazar la decisión con `args.async = true` y `args.loaded()`.
4. Se actualiza el almacén (con `eventMoveHandling: 'Update'`, el valor por defecto).
5. **`onEventMoved`** se ejecuta tras la confirmación: `args.control.events.find(id)` ya devuelve las horas nuevas.
6. **`onEventsChange`** se ejecuta en la siguiente microtarea con `reason: 'move'`.

El redimensionado sigue la misma secuencia con `onEventResizing`, `onEventResize`, `onEventResized` y `reason: 'resize'`. Como `onEventMoved` se ejecuta antes de que React haya guardado nada, lee los valores nuevos de sus argumentos, no de tu estado.

## Dónde entra tu backend
El scheduler nunca llama a un servidor. Tú decides cuándo guardar, y hay dos diseños sólidos.

### Confirmar antes de aplicar el cambio
Guarda dentro de `onEventMove` o `onEventResize` con `args.async = true` y llama a `args.loaded()` cuando responda el servidor; si lo rechazó, llama antes a `args.preventDefault()`. Hasta entonces, el evento se queda donde estaba, así que la pantalla nunca muestra un cambio que el servidor haya rechazado. El precio es una latencia visible en cada operación de soltar. El patrón completo está en [Confirmar al soltar](https://superscheduler.org/es/docs/drag-resize-rules/#async-confirmation).

### Guardar de forma optimista y revertir si falla
Acepta el cambio en el acto, guarda en segundo plano y vuelve a poner el objeto anterior si el guardado falla. `onEventsChange` tiene todo lo necesario: `changed` es lo que hay que guardar y `removed` lo que hay que restaurar.

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

const iso = (value: SuperScheduler.DateInput) => (typeof value === 'string' ? value : value.value)

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  readonly initial: SuperScheduler.EventData[]
}

export function OptimisticPlanning({ rooms, initial }: Props) {
  const [events, setEvents] = useState(initial)
  const owned = useMemo(() => events.slice(), [events])
  const { controlRef } = useSchedulerControl()

  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => {
      // 1. Show the change immediately.
      setEvents([...args.events])
      if (args.reason !== 'move' && args.reason !== 'resize') return

      for (const after of args.changed) {
        // The object this drop replaced: the state to restore if the server says no.
        const before = args.removed.find((item) => item.id === after.id)
        if (before === undefined || after.resource === undefined) continue

        // 2. Persist it.
        saveBooking({
          id: String(after.id),
          resource: String(after.resource),
          start: iso(after.start),
          end: iso(after.end),
        })
          // 3. Revert on failure. Matching by identity leaves a newer change of the same event alone.
          .catch(() => {
            setEvents((current) => current.map((item) => (item === after ? before : item)))
            controlRef.current?.message('The change could not be saved and was undone.')
          })
      }
    },
    [controlRef],
  )

  return (
    <SuperSchedulerComponent
      controlRef={controlRef}
      startDate="2026-10-01"
      days={31}
      scale="Day"
      resources={rooms}
      events={owned}
      onEventsChange={onEventsChange}
    />
  )
}
```
Deberías ver que soltar surte efecto de inmediato. Si `saveBooking` rechaza la promesa, la reserva vuelve a su sitio anterior y un mensaje explica por qué. La reversión compara por identidad de objeto, así que si el usuario ha vuelto a mover la misma reserva entretanto, el cambio más reciente no se toca.

Elijas el diseño que elijas, mantén estas responsabilidades en tu aplicación:

- **Valida en el servidor.** Las reglas de `onEventMoving` son experiencia de usuario; el servidor debe volver a comprobar solapes, permisos y reglas de negocio, porque otros usuarios y otros clientes cambian los mismos datos.
- **Normaliza lo que envías.** Los eventos movidos llevan valores `SuperScheduler.Date`; los que nadie ha tocado llevan tus cadenas. Consulta [valores después de arrastrar](https://superscheduler.org/es/docs/resources-events-intervals/#after-drag).
- **Adopta la versión del servidor.** Si el servidor devuelve un objeto canónico (un id nuevo para un evento creado, un precio recalculado), sustituye el objeto en el estado. El control lo recarga e informa con `reason: 'load'`.

## Deshacer, paneles y carga por rangos
- **Deshacer y rehacer.** `createHistory({ apply })` de `super-scheduler/history` puede aplicar deshacer y rehacer a tu estado en lugar de al control. Consulta [Deshacer y rehacer](https://superscheduler.org/es/docs/undo-redo/).
- **Varios paneles.** `SchedulerPanes` comparte una lista de eventos entre paneles mediante `events` y `onEventsChange` controlados (o `defaultEvents`). Consulta [Paneles y vistas guardadas](https://superscheduler.org/es/docs/panes-saved-views/).
- **Carga por rango de fechas.** Un cargador de rangos de `super-scheduler/ranges` fusiona lo que carga y lo comunica mediante `onEventsChange` con `reason: 'load'`; adopta esa lista. Consulta [Carga por rangos](https://superscheduler.org/es/docs/range-loading/).

→ https://superscheduler.org/es/examples/field-service-dispatch/
→ https://superscheduler.org/es/examples/training-rooms/
## Siguientes pasos
- Rechaza movimientos no válidos mientras el usuario arrastra: [Arrastrar, redimensionar y reglas de negocio](https://superscheduler.org/es/docs/drag-resize-rules/).
- Tipa tus campos propios de principio a fin: [Campos propios con EventData&lt;T&gt;](https://superscheduler.org/es/docs/resources-events-intervals/#custom-fields).
- Accede al control desde código React: [Integración con React](https://superscheduler.org/es/docs/react-integration/#control).
