# Controlled events and callbacks

> Keep events in React state with onEventsChange, give the control its own copy, learn the callback order of a drop, and save changes optimistically or after confirmation.

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

Pass events from React state and write them back in onEventsChange, which the control calls once per task after a drop, a resize or a control.events call, with the new list, the changed and removed objects and a reason. Give the control a copy of your array, because it adopts the array and edits it in place. The library never talks to your backend: save from onEventMove to confirm before the change commits, or from onEventsChange to save optimistically and revert on failure.

SuperScheduler Pro supports two ways of owning event data. **Controlled**: React state is the source of truth, you pass it as `events`, and `onEventsChange` tells you what the user or the API changed. **Uncontrolled**: you hand initial data with `defaultEvents` and the control keeps its own list. Controlled is the right default for an application that saves changes, shows them elsewhere on the page or supports undo.

This page explains the controlled pattern, what the change callback receives, the array ownership rules that make it work, the exact order of callbacks during a drop, and where your backend comes in.

## The controlled pattern
```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}
      />
    </>
  )
}
```
You should see two bookings and an event counter. Drag a booking to the other room: it stays where you dropped it, because its new position is now in React state. Press the button: the counter goes up and a maintenance block appears on Room 102, locked against moving and resizing.

Three lines carry the pattern:

1. `useState` holds the events. Everything that displays or edits them reads this state.
2. `useMemo(() => events.slice(), [events])` gives the control **its own copy** of the array (see [array ownership](#ownership)).
3. `onEventsChange` writes the control's new list back to state with `setEvents([...args.events])`.

When state changes for another reason (a form, a server push, the button above), the new array reaches the control as a changed prop, and the control reloads it.

## What onEventsChange receives
`onEventsChange` is called after the control's event store changes, **at most once per task**: several changes made in the same synchronous block arrive together in one call, on the next microtask.

| Argument | Contents |
|---|---|
| `events` | The control's complete list after the change, as data objects |
| `changed` | Objects added or replaced by this change, in their new state |
| `removed` | Objects removed or replaced by this change, in their previous state |
| `reason` | Why the store changed (below) |

| `reason` | Triggered by |
|---|---|
| `'move'` | A drag-and-drop committed, also with the keyboard, and drops from outside the scheduler |
| `'resize'` | A resize committed |
| `'create'` | `control.events.add()` |
| `'update'` | `control.events.update()` with a new object, or an add and a remove in the same task |
| `'remove'` | `control.events.remove()`, including the built-in delete button (`eventDeleteHandling: 'Update'`) |
| `'history'` | Undo or redo applied by the control through `super-scheduler/history` |
| `'load'` | You passed different event objects as `events`, or a range loader merged newly loaded events |
| `'api'` | Other changes the library makes to the store on its own; treat them like `'update'` |

For a move, `changed` holds the new object and `removed` the object it replaced: you have the before and after states without keeping your own copy. The objects in `events` keep their identity between calls unless they changed, so `React.memo` and selectors that compare by reference keep working.

## Uncontrolled: defaultEvents
Pass `defaultEvents` instead of `events` when the scheduler may own the data, for example in a read-mostly view or a prototype. The array is read once, during initialization; later changes to the prop are ignored, with a development warning. If you pass both, `events` wins, also with a warning.

In this mode, read the current data from `control.events.list`, subscribe with `useScheduler({ track: ['events'] })` from `super-scheduler/hooks`, or still listen to `onEventsChange`, which works in both modes.

## The control adopts your array
For speed, the control does not copy the array you pass as `events`: `control.events.list` **is** that array, and adds, removals and drops edit it in place with `splice`. This is why the pattern above passes a copy. Without the copy, the control would mutate the array inside your React state, behind React's back.

The same rule explains the other behaviors of the controlled loop:

- **Echoes are free.** When `onEventsChange` stores `[...args.events]`, React renders, and the control receives an array containing the very objects it already holds. It recognizes the echo and does nothing: no reload, no repaint.
- **New objects reload.** When your state contains objects the control has not seen (a form edit, a server response), it reloads its list from the new array and then reports `reason: 'load'`. Storing that list again is an echo, so the loop ends there.
- **Do not freeze the array** if you call `control.events.add`, `update` or `remove`: they edit it in place and throw a `TypeError` on a frozen array. Drops and resizes copy a frozen array first.

> **Behavior:**
> A drop never edits your event object: the library replaces it with a new object, `{ ...old, start, end, resource }`, whose `start` and `end` are `SuperScheduler.Date` values. The setters of the event wrapper (`e.start(value)`, `e.end(value)`) are different: they write into the existing data object. In controlled mode, prefer `control.events.update({ ...e.data, end })` with a new object, or change your state directly.

## Callback order of a drop
Each drag-and-drop runs a fixed sequence. This logger shows it:

```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`** runs on every shadow change while the user drags. It can refuse the position or adjust it (see [Drag, resize and business rules](https://superscheduler.org/en/docs/drag-resize-rules/)).
2. On release, if the last position was refused (by your rule, an overlap, a disabled cell), **nothing else runs**: no `onEventMove`, no change.
3. **`onEventMove`** runs once, before the store changes. It can cancel with `args.preventDefault()`, change `args.newStart`, `args.newEnd` or `args.newResource`, or defer the decision with `args.async = true` and `args.loaded()`.
4. The store is updated (with `eventMoveHandling: 'Update'`, the default).
5. **`onEventMoved`** runs after the commit: `args.control.events.find(id)` already returns the new times.
6. **`onEventsChange`** runs on the next microtask with `reason: 'move'`.

Resizing follows the same sequence with `onEventResizing`, `onEventResize`, `onEventResized` and `reason: 'resize'`. Because `onEventMoved` runs before React has stored anything, read the new values from its arguments, not from your state.

## Where your backend comes in
The scheduler never calls a server. You decide when to save, and there are two sound designs.

### Confirm before the change commits
Save inside `onEventMove` or `onEventResize` with `args.async = true`, and call `args.loaded()` when the server answers; call `args.preventDefault()` first if it refused. The event stays where it was until then, so the screen never shows a change the server rejected. The cost is visible latency on every drop. The full pattern is in [Confirm on drop](https://superscheduler.org/en/docs/drag-resize-rules/#async-confirmation).

### Save optimistically and revert on failure
Accept the change at once, save in the background, and put the previous object back if the save fails. `onEventsChange` has everything needed: `changed` is what to save, `removed` is what to restore.

```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}
    />
  )
}
```
You should see a drop take effect immediately. If `saveBooking` rejects, the booking jumps back to its previous place and a message explains why. The revert matches by object identity, so if the user moved the same booking again in the meantime, the newer change is left alone.

Whichever design you choose, keep these responsibilities in your application:

- **Validate on the server.** Rules in `onEventMoving` are user experience; the server must check overlaps, permissions and business rules again, because other users and other clients change the same data.
- **Normalize what you send.** Moved events carry `SuperScheduler.Date` values; untouched ones carry your strings. See [values after a drag](https://superscheduler.org/en/docs/resources-events-intervals/#after-drag).
- **Adopt the server's version.** If the server returns a canonical object (a new id for a created event, a recalculated price), replace the object in state. The control reloads and reports `reason: 'load'`.

## Undo, panes and range loading
- **Undo and redo.** `createHistory({ apply })` from `super-scheduler/history` can apply undo and redo to your state instead of the control. See [Undo and redo](https://superscheduler.org/en/docs/undo-redo/).
- **Several panes.** `SchedulerPanes` shares one event list between panes through controlled `events` and `onEventsChange` (or `defaultEvents`). See [Panes and saved views](https://superscheduler.org/en/docs/panes-saved-views/).
- **Loading by date range.** A range loader from `super-scheduler/ranges` merges what it loads and reports it through `onEventsChange` with `reason: 'load'`; adopt that list. See [Range loading](https://superscheduler.org/en/docs/range-loading/).

→ https://superscheduler.org/en/examples/field-service-dispatch/
→ https://superscheduler.org/en/examples/training-rooms/
## Next steps
- Refuse invalid moves while the user drags: [Drag, resize and business rules](https://superscheduler.org/en/docs/drag-resize-rules/).
- Type your custom fields end to end: [Custom fields with EventData&lt;T&gt;](https://superscheduler.org/en/docs/resources-events-intervals/#custom-fields).
- Reach the control from React code: [React integration](https://superscheduler.org/en/docs/react-integration/#control).
