Skip to content
SuperScheduler

Core conceptsApplies toSuperScheduler Pro

Controlled events and callbacks

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.

Verified against v0.1.0 · reviewed October 7, 2026.md

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

src/ControlledPlanning.tsxtsx
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).
  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.

ArgumentContents
eventsThe control's complete list after the change, as data objects
changedObjects added or replaced by this change, in their new state
removedObjects removed or replaced by this change, in their previous state
reasonWhy the store changed (below)
reasonTriggered 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.

Callback order of a drop

Each drag-and-drop runs a fixed sequence. This logger shows it:

src/tracing.tsts
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).
  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.

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.

src/OptimisticPlanning.tsxtsx
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.
  • 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 and redo. createHistory({ apply }) from super-scheduler/history can apply undo and redo to your state instead of the control. See Undo and redo.
  • Several panes. SchedulerPanes shares one event list between panes through controlled events and onEventsChange (or defaultEvents). See Panes and 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.

Field service dispatchAn urgent job lands in the queue. Find the crew that can take it before it is due. Training room schedulingEnrolment outgrew the room. Select both sittings, see what is free for both, move them together and keep the view.

Next steps