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.
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
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:
useStateholds the events. Everything that displays or edits them reads this state.useMemo(() => events.slice(), [events])gives the control its own copy of the array (see array ownership).onEventsChangewrites the control's new list back to state withsetEvents([...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
onEventsChangestores[...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,updateorremove: they edit it in place and throw aTypeErroron 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:
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),
}onEventMovingruns on every shadow change while the user drags. It can refuse the position or adjust it (see Drag, resize and business rules).- On release, if the last position was refused (by your rule, an overlap, a disabled cell), nothing else runs: no
onEventMove, no change. onEventMoveruns once, before the store changes. It can cancel withargs.preventDefault(), changeargs.newStart,args.newEndorargs.newResource, or defer the decision withargs.async = trueandargs.loaded().- The store is updated (with
eventMoveHandling: 'Update', the default). onEventMovedruns after the commit:args.control.events.find(id)already returns the new times.onEventsChangeruns on the next microtask withreason: '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.
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
onEventMovingare 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.Datevalues; 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, panes and range loading
- Undo and redo.
createHistory({ apply })fromsuper-scheduler/historycan apply undo and redo to your state instead of the control. See Undo and redo. - Several panes.
SchedulerPanesshares one event list between panes through controlledeventsandonEventsChange(ordefaultEvents). See Panes and saved views. - Loading by date range. A range loader from
super-scheduler/rangesmerges what it loads and reports it throughonEventsChangewithreason: '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
- Refuse invalid moves while the user drags: Drag, resize and business rules.
- Type your custom fields end to end: Custom fields with EventData<T>.
- Reach the control from React code: React integration.