Skip to content
SuperScheduler

Pro modulesApplies toSuperScheduler Pro

Undo, redo and history

Create one history with createHistory() and pass it as the history prop: moves and resizes are recorded, and Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z and Ctrl+Y work while focus is in the scheduler. With controlled events, adopt the 'history' changes from onEventsChange, or give createHistory an apply function that updates your state. Use push for your own commands, batch to group changes, revert to undo a change your server refused, and subscribe or onHistoryChange to drive Undo and Redo buttons.

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

Direct manipulation invites mistakes: an event dropped one row too low, a resize that went a day too far. super-scheduler/history keeps an undo stack of event changes and of your own commands, with keyboard shortcuts, labels for buttons, grouping, and a rollback for changes your server rejects. It lives in memory for the session; saving anything is your application's job.

History needs SuperScheduler Pro.

Attach a history

createHistory() returns a history object. Pass it to the scheduler as history (or inside extensions). Create it once: props are compared by identity, and a history created during render would be a new, empty one on every render.

src/UndoablePlanner.tsxtsx
import { useEffect, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerHistoryChangeArgs, SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'
import type { SchedulerHistory } from 'super-scheduler/history'
import 'super-scheduler/styles.css'

type HistoryState = Pick<
  SchedulerHistoryChangeArgs,
  'canUndo' | 'canRedo' | 'undoLabel' | 'redoLabel'
>

/** Mirrors the history in React: one render per change, never per gesture frame. */
export function useHistoryState(history: SchedulerHistory): HistoryState {
  const [state, setState] = useState<HistoryState>(() => ({
    canUndo: history.canUndo,
    canRedo: history.canRedo,
    undoLabel: history.undoLabel,
    redoLabel: history.redoLabel,
  }))
  useEffect(() => history.subscribe(setState), [history])
  return state
}

export function UndoablePlanner(props: {
  resources: SuperScheduler.ResourceData[]
  initialEvents: SuperScheduler.EventData[]
}) {
  // Created once: `history` is compared by identity, like every prop.
  const [history] = useState(() =>
    createHistory({
      limit: 100,
      // Default: moves and resizes. Mod+Z, Mod+Shift+Z and Ctrl+Y work while focus is in the grid.
      record: ['move', 'resize'],
      keys: 'root',
      labels: { move: 'move', resize: 'resize' },
    }),
  )
  const [initial] = useState(() => props.initialEvents.slice())
  const state = useHistoryState(history)

  return (
    <>
      <div role="toolbar" aria-label="History">
        <button type="button" disabled={!state.canUndo} onClick={() => history.undo()}>
          {state.undoLabel === null ? 'Undo' : `Undo ${state.undoLabel}`}
        </button>
        <button type="button" disabled={!state.canRedo} onClick={() => history.redo()}>
          {state.redoLabel === null ? 'Redo' : `Redo ${state.redoLabel}`}
        </button>
      </div>
      <SuperSchedulerComponent
        history={history}
        // Uncontrolled: the control owns the events after mount.
        defaultEvents={initial}
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}

Drag an event and the first button reads "Undo move". Click it, or press Cmd+Z (Ctrl+Z on Windows and Linux) after clicking an event, and the event goes back; "Redo move" brings it again.

One history can serve several schedulers, for example the panes of a split view. Undo then follows the order of changes across all of them.

Options

OptionDefaultEffect
limit50Entries kept; the oldest is dropped beyond it. 0 records nothing.
record['move', 'resize']Changes recorded automatically: 'move', 'resize', 'create', 'remove', 'update'
keys'root'Where shortcuts are heard: inside the scheduler, the whole 'document', or false for none
equalsstart, end, resource, textTwo event states that compare equal make no entry
fieldsnoneExtra fields for the default comparison
apply'control'Who applies undo and redo: the control, or your function
labelsEnglish or SpanishLabel per kind: move, resize, create, remove, update, command

What gets recorded:

  • Gestures. 'move' and 'resize' cover pointer and keyboard moves and resizes. An entry is made after the change is confirmed: a move refused by your rules, or cancelled during an asynchronous confirmation, leaves no entry.
  • API changes. 'create', 'remove' and 'update' record calls to control.events.add(), remove() and update().
  • Never. Data loads: a new events array from React, and events from the range loader. Undo and redo themselves are not recorded again.

The shortcuts are Cmd+Z and Cmd+Shift+Z on macOS, and Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y elsewhere. They are ignored in inputs, text areas and editable elements, with Alt held, and when another handler already handled the key. With keys: 'root', focus must be inside the scheduler: clicking or dragging an event puts it there, and so does Tab when the keyboard is enabled. 'document' works anywhere on the page, so use it only when no other part of the page has its own undo.

Show undo state

The history object exposes canUndo, canRedo, undoLabel and redoLabel, and undo() and redo(), which return false when there is nothing to do. Three ways to follow changes:

  • history.subscribe(listener) returns an unsubscribe function, which makes it a natural useEffect (the snippet's useHistoryState);
  • the onHistoryChange prop receives the same state with this set to the control;
  • useScheduler({ track: ['history'] }) from super-scheduler/hooks exposes it as React state.

Each notification includes a cause: 'record', 'undo', 'redo', 'clear' or 'revert'. Labels are your label, or the default for the kind, in English, or in Spanish when the scheduler's locale starts with es. Pass labels for other languages.

Controlled events

When React state owns the events (events plus onEventsChange), there are two ways to apply undo.

With the default apply: 'control', undo changes the control's events through control.events.*, and the result reaches onEventsChange with reason: 'history'. If you already adopt every change there, undo works without more code.

With an apply function, the history hands you the operations and you update your state; the new events prop then reaches the control. This suits stores, reducers and apps that save every change through one code path.

src/ControlledUndo.tsxtsx
import { useCallback, useMemo, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'
import type { HistoryOp } from 'super-scheduler/history'

type EventData = SuperScheduler.EventData

/** Applies history operations to React state. They arrive in order (reversed for undo). */
function applyOps(
  events: EventData[],
  ops: readonly HistoryOp[],
  direction: 'undo' | 'redo',
): EventData[] {
  let next = events
  for (const op of ops) {
    const target = direction === 'undo' ? op.before : op.after
    const id = (target ?? op.before ?? op.after)?.id
    if (id === undefined) continue
    const index = next.findIndex((event) => event.id === id)
    if (target === null) next = next.filter((event) => event.id !== id)
    else if (index >= 0) next = next.map((event, i) => (i === index ? target : event))
    else next = [...next, target]
  }
  return next
}

export function ControlledUndo(props: {
  resources: SuperScheduler.ResourceData[]
  initial: EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  // The control adopts the array it receives and mutates it: give it a copy.
  const owned = useMemo(() => events.slice(), [events])

  const [history] = useState(() =>
    createHistory({
      record: ['move', 'resize'],
      // Undo and redo update React state; the new `events` prop then reaches the control.
      apply: (ops, direction) => setEvents((current) => applyOps(current, ops, direction)),
    }),
  )

  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => setEvents([...args.events]),
    [],
  )

  const onTimeRangeSelected = useCallback(
    (args: SuperScheduler.SchedulerTimeRangeSelectedArgs) => {
      if (args.origin !== 'drag') return
      args.control.clearSelection()
      const booking: EventData = {
        id: SuperScheduler.guid(),
        resource: args.resource,
        start: args.start,
        end: args.end,
        text: 'New booking',
      }
      setEvents((current) => [...current, booking])
      // A change made through state is a data load for the control, not a gesture:
      // record it explicitly so it can be undone.
      history.record({
        kind: 'create',
        label: 'new booking',
        ops: [{ before: null, after: booking }],
      })
    },
    [history],
  )

  return (
    <SuperSchedulerComponent
      history={history}
      events={owned}
      onEventsChange={onEventsChange}
      onTimeRangeSelected={onTimeRangeSelected}
      resources={props.resources}
      startDate="2026-10-01"
      days={31}
      scale="Day"
    />
  )
}

Each operation is { before, after }. before: null means the event was created, after: null that it was removed. Operations arrive in the order to apply them, already reversed for undo.

Group changes and add commands

history.batch(label, run) turns everything recorded while run executes into a single entry. Bulk actions then undo in one step. Batches can nest; the outer label wins.

history.push({ label, undo, redo }) adds a command of your own, for changes outside the scheduler's events: a frozen day, a resource setting, a dependency between events. Undo and redo call your functions.

src/BulkEditing.tsxtsx
import { useState } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'

export function BulkEditing(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const { controlRef, control } = useSchedulerControl()
  const [initial] = useState(() => props.initial.slice())
  const [frozenDays, setFrozenDays] = useState<ReadonlySet<string>>(new Set())
  // 'update' also records changes made through control.events.update().
  const [history] = useState(() => createHistory({ record: ['move', 'resize', 'update'] }))

  // One undo step for the whole operation.
  const shiftSelected = (days: number) => {
    if (control === null) return
    const selected = control.multiselect.get()
    history.batch(`shift ${selected.length} jobs`, () => {
      for (const e of selected) {
        // A new object: the stored one stays intact as the state undo restores.
        // (The wrapper setters e.start(...) edit the stored object in place.)
        control.events.update({
          ...e.data,
          start: e.start().addDays(days),
          end: e.end().addDays(days),
        })
      }
    })
  }

  // A change outside the scheduler's events, undone through the same history.
  const freezeDay = (day: string) => {
    const add = () => setFrozenDays((current) => new Set(current).add(day))
    const remove = () =>
      setFrozenDays((current) => new Set([...current].filter((item) => item !== day)))
    add()
    history.push({ label: `freeze ${day}`, undo: remove, redo: add })
  }

  return (
    <>
      <button type="button" onClick={() => shiftSelected(1)}>
        Move selection one day later
      </button>
      <button type="button" onClick={() => freezeDay('2026-10-12')}>
        Freeze 12 October
      </button>
      <p>Frozen days: {[...frozenDays].join(', ') || 'none'}</p>
      <SuperSchedulerComponent
        controlRef={controlRef}
        history={history}
        defaultEvents={initial}
        eventClickHandling="Select"
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}

Select two events (click, then Cmd+click), press "Move selection one day later", then "Freeze 12 October". The first undo unfreezes the day; the second moves both events back at once.

Server-confirmed workflows

The library never calls your backend. Two patterns cover most applications.

Optimistic. Let the move happen and be recorded, save it in onEventMoved, and roll back if the server refuses. history.revert(eventId) restores the event to the state before its latest entry and drops that entry, so a later undo does not resurrect the refused change.

src/OptimisticPlanner.tsxtsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'

export function OptimisticPlanner(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  const owned = useMemo(() => events.slice(), [events])
  const [history] = useState(() => createHistory())

  const config = useMemo<SchedulerProps>(
    () => ({
      history,
      onEventsChange: ({ events: next }) => setEvents([...next]),
      // The move is already applied and recorded: save it, and roll back if the server refuses.
      onEventMoved: (args) => {
        const id = args.e.id()
        saveBooking({
          id: String(id),
          resource: String(args.newResource),
          start: args.newStart.value,
          end: args.newEnd.value,
        }).catch(() => {
          // Restores the event's previous state and drops that history entry.
          history.revert(id)
          args.control.message('The move could not be saved and was undone.')
        })
      },
    }),
    [history],
  )

  const undo = useCallback(() => history.undo(), [history])

  return (
    <>
      <button type="button" onClick={undo}>
        Undo
      </button>
      <SuperSchedulerComponent
        {...config}
        events={owned}
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}

When the save fails, the event jumps back and the message bar explains why.

Confirm first. Ask the server (or the user) before the change is applied: set args.async = true in onEventMove, then call args.loaded() to accept or args.preventDefault() and args.loaded() to refuse. The history records the move only once it is accepted. For flows where the confirmed change differs from the gesture (a dialog that edits the result), set record: [] and push a command after the server confirms.

Undo and redo are changes too: save them the same way, either in your apply function or when onEventsChange reports reason: 'history'. Detecting that someone else changed the event in the meantime (versions, conflicts) is your backend's job.

What history does not do

Video production planningA shoot runs long. Move the edit that depended on it, see why, and take it back. Creative agency resource planningA designer is double-booked. Hand two tasks to a colleague in one drag, check what they unblock, and take it back.