# Undo, redo and history

> Record moves and resizes with super-scheduler/history, apply undo to controlled state, group bulk changes, add your own commands and roll back what the server refuses.

Source: https://superscheduler.org/en/docs/undo-redo/
Reviewed: 2026-10-07

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.

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.

```tsx
// src/UndoablePlanner.tsx
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
| Option | Default | Effect |
|---|---|---|
| `limit` | `50` | Entries 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 |
| `equals` | start, end, resource, text | Two event states that compare equal make no entry |
| `fields` | none | Extra fields for the default comparison |
| `apply` | `'control'` | Who applies undo and redo: the control, or your function |
| `labels` | English or Spanish | Label 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.

> **Behavior:**
> History compares the stored event object before and after a change. The wrapper setters (`e.start(value)`, `e.end(value)`, `e.text(value)`) edit the stored object in place, so a `control.events.update(e)` after them leaves nothing to restore and makes no entry. Pass a new object instead: `control.events.update({ ...e.data, start, end })`.

## 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.

```tsx
// src/ControlledUndo.tsx
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.

> **Tip:**
> A change your application makes by setting state is a data load for the control, not a gesture, so nothing records it. Record it yourself with `history.record({ kind, label, ops })`, as the snippet does when it creates a booking. When one history serves several schedulers, pass `control` too.

## 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.

```tsx
// src/BulkEditing.tsx
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.

```tsx
// src/OptimisticPlanner.tsx
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
> **Limitation:**
> History is in memory, per page: it does not survive a reload and is not shared between tabs or users. It records events only; resources, links, selection, zoom and scroll are not recorded unless you push your own commands (see [saved views](https://superscheduler.org/en/docs/panes-saved-views/) for zoom and scroll). Call `history.clear()` when you load a different dataset, so undo cannot apply old states to new data.

## Related
→ https://superscheduler.org/en/examples/video-production/
→ https://superscheduler.org/en/examples/agency-campaigns/
- [Controlled events and callbacks](https://superscheduler.org/en/docs/controlled-state/) for `onEventsChange` and its reasons.
- [Drag, resize and business rules](https://superscheduler.org/en/docs/drag-resize-rules/) for asynchronous confirmation.
