Moltenship Time

Undo and redo

Record the changes people make, and take them back through the same pipeline that saved them.

Live. Drag, resize, create or delete a meeting, then undo it from the toast, the toolbar, or with ⌘Z or Ctrl+Z.

An event history records the changes your calendar commits, and undoes them by proposing the inverse change. The inverse goes through the same pipeline as a drag: your policies check it, the calendar shows it while it saves, and your onEventChange saves it. Redo proposes the change again.

It's opt-in, and each part is separate: recording, the buttons you draw, the keyboard shortcuts and the registry's undo toast. A calendar without a history records nothing, listens for no keys, and bundles none of its code.

This page uses the Calendar and Meeting from the Quick start. The example above also renders the undo toast.

Connect a history

Create a history with createEventHistory and pass its record to Root's onEventChangeResult, which hears how every proposal ended. If you have your own onEventChangeResult, call history.record(result, detail) from it.

undoable-calendar.tsx
export function UndoableCalendar() {
  const [meetings, setMeetings] = useState(initialMeetings);
  // The meeting each create became, by change ID, so the history can find it to undo.
  const [createdIds] = useState(() => new Map<string, string>());
  const [history] = useState(() =>
    createEventHistory<Meeting, string, never>({
      limit: 50,
      // Once the meetings show it.
      createdEventId: (change, events) => {
        const id = createdIds.get(change.id);
        return id !== undefined && events.get(id) !== undefined ? id : undefined;
      },
    }),
  );

  return (
    <Calendar.Root
      events={meetings}
      onEventChange={(change) => {
        // A restored meeting keeps its ID; one made from a range gets a new one.
        const id =
          change.cause === "create" ? (change.event?.id ?? crypto.randomUUID()) : undefined;
        if (id !== undefined) createdIds.set(change.id, id);
        setMeetings((current) => applyChange(current, change, id));
      }}
      onEventChangeResult={history.record}
      policies={[timedOnly]}
      defaultAnchorDate={Temporal.PlainDate.from("2026-10-08")}
      defaultTimeZone="Europe/Berlin"
      locale="en-US"
      weekRules={weekRules}
    >
      <Calendar.Frame className="flex h-full flex-col">
        <CalendarToolbar calendar={Calendar}>
          <UndoButtons history={history} />
        </CalendarToolbar>
        <Calendar.Viewport className="min-h-0 flex-1">
          <Calendar.ActiveView />
        </Calendar.Viewport>
      </Calendar.Frame>
      <Calendar.Announcer messages={calendarAnnouncements} />
      {/* An Undo toast after each change, with Mod+Z and Shift+Mod+Z in the frame. */}
      <CalendarHistoryToast calendar={Calendar} history={history} />
    </Calendar.Root>
  );
}

Inside Root, useCalendarHistory(history) binds the history to the calendar: undo() and redo() propose through the root's proposeEventChange, and canUndo and canRedo say whether there is anything to take.

undoable-calendar.tsx
// Anywhere inside Root.
function UndoButtons({ history }: { readonly history: MeetingHistory }) {
  const { canRedo, canUndo, redo, undo } = useCalendarHistory(history, {
    messages: calendarHistoryAnnouncements,
  });

  return (
    <div className="flex gap-1">
      <Button disabled={!canUndo} onClick={() => undo()} variant="ghost">
        Undo
      </Button>
      <Button disabled={!canRedo} onClick={() => redo()} variant="ghost">
        Redo
      </Button>
    </div>
  );
}

useCalendarHistory returns:

FieldContents
undo({ input? })Takes the last step back. Resolves with the outcome
redo({ input? })Takes the last undone step forward again. Resolves with the outcome
canUndoWhether there is a step to undo
canRedoWhether there is a step to redo
past, futureThe steps undo and redo take, oldest first: each takes the last
pending{ direction, step } while an undo or redo waits for its result, or null
latestThe latest thing the history did: { type: "record", step } or { type: "undo" | "redo", outcome }

input says where the command came from, "api" by default. Your onEventChangeResult gets it back, with history: "undo" or "redo" on the detail, so it can tell an undo from a drag. The server and hydration read an empty history.

Keyboard shortcuts

useCalendarHistoryShortcuts(history, { scope }) adds undo and redo keys to an element, usually the root's frame. They're Mod+Z to undo and Shift+Mod+Z or Ctrl+Y to redo, where Mod is ⌘ on Apple platforms and Ctrl elsewhere.

const history = useCalendarHistory(eventHistory, { messages: calendarHistoryAnnouncements });
useCalendarHistoryShortcuts(history, { scope: Calendar.useCalendarMeta().refs.frame });
OptionDefaultEffect
scoperequiredThe element whose keys undo and redo
undo["Mod+Z"]Keys that undo: modifiers joined by +, then the key
redo["Shift+Mod+Z", "Ctrl+Y"]Keys that redo. [] turns an action's keys off
disabledfalseListens for nothing

While it's mounted and enabled, the hook adds one keydown listener to the document, which acts only on keys whose target is inside the scope, after the page's own handlers, React's included. Keys elsewhere on the page are left alone, as are dialogs rendered in a portal outside the scope, and so are keys:

  • in a text field or an editable element, where the browser undoes the text;
  • in a dialog, menu or list inside the scope;
  • during a keyboard grab or a pointer drag, or while an input method composes text;
  • that something else already handled.

A key matches by the letter it types. One that types no Latin letter, as on a Russian layout or with Option on a Mac, matches by its position, so Ctrl+Z still works there. Commands from the keys pass { input: "keyboard" }.

Undo toast

The registry's calendar-history-toast item is an "Undo" toast built on Base UI's Toast. After each change it offers to undo it, and after an undo, to redo it. It also adds the keys above in the calendar's frame and speaks undo and redo through the Announcer. Install it with the shadcn CLI, then render it inside Root:

import { CalendarHistoryToast } from "@/components/calendar/calendar-history-toast/calendar-history-toast";

<Calendar.Root onEventChangeResult={history.record} …>
  …
  <CalendarHistoryToast calendar={Calendar} history={history} />
</Calendar.Root>;
PropDefaultEffect
calendarrequiredYour calendar, for its frame and event titles
historyrequiredThe history onEventChangeResult records into
messagescalendarHistoryToastMessagesWhat the toast says: English, in the item's file, to edit or translate
announcementscalendarHistoryAnnouncementsWhat the Announcer says after an undo or redo; null says nothing
shortcutsthe default keys{ undo?, redo? } to change them, false to turn them off
timeout6000How long a toast stays, in milliseconds
classNameClasses for the toast's region, such as where it sits

One toast shows at a time, and a cleared history closes it. A message that returns null shows no toast and closes the one on screen: the English set does so for an empty stack or a change you cancelled. The toast's region doesn't speak, so nothing is said twice: changes made by a gesture are announced as they're made, and undo and redo by the Announcer. Deletes, updates and changes from your own UI, which no gesture announced, are spoken once with how to reach Undo (recordedAnnouncement), and that toast stays at least 10 seconds, long enough to hear it out and press F6. The region's name is region.

What gets recorded

A history records a change only when it committed: a policy's rejection, a failed save, a cancel or a proposal a newer one superseded never becomes a step. Its own undos and redos don't either. A new step clears what redo would take forward.

Each step changes one event: where it was before (before) and where the step left it (after), with null where there's no event, as before a create or after a delete. A step also keeps its cause, the input that made it, your record (event), the event's ID and the changes it was made of.

createEventHistory takes these options:

OptionDefaultEffect
limit100The most steps the undo stack keeps; the oldest drops. 0 records nothing
inputsall threeThe inputs whose changes are recorded: "pointer", "keyboard", "api"
filter(change, detail)keeps allRecords a change only when it returns true
coalesce(change, step, detail)neverReturn true to fold a change into the step on top, when both change the same event: the step keeps its before, takes the new after
createdEventId(change, events)noneThe event a committed create became. Without it, creates aren't recorded. See creates and deletes

A keyboard grab is already one step: arrow keys move its preview, and Enter proposes once. coalesce is for several proposals you want undone together, such as nudges from your own buttons. A step that spans several events isn't supported yet.

How undo works

StepUndo proposesRedo proposes
move, resize, updatethe same cause, with the placement before, resources includedthe same, with the placement after
deletea create with the deleted record: { cause: "create", event, proposed }a delete of the restored event
createa delete of the event it becamea create with the record the undo removed

Undos and redos run one at a time: pressing undo three times undoes three steps, in order. An undo pressed while a change still saves waits for it, so it takes that change back rather than the one before. It waits for every change in the calendar, so a save that never ends holds undo and redo. When an inverse commits, its step moves to the other stack, with the placement that was saved, since a policy may have adjusted it. A redo that commits after a new change goes back on top of the undo stack.

Outcomes

undo() and redo() resolve with one of these. Through useCalendarHistory, they reject only if your createdEventId throws.

statusWhen
"committed"The inverse saved. result is its transaction result, step the step as it is now
"rejected"A policy rejected the inverse, the save failed, or a drag superseded it. Or createdEventId can't name the event yet (unknown-event). The step stays where it was, so the user can try again
"stale"The step's event changed elsewhere since. The step is dropped and nothing is proposed
"empty"There was nothing to undo or redo

Changes from elsewhere

Before it proposes, the history checks that the event is still where the step left it: at after for an undo, at before for a redo, gone for an undone create, and so on. If another user moved it, a server push deleted it, or someone restored it first, the step is stale. The history drops it rather than move the event back over that change, and the next undo goes on with the next step.

Only the placement counts. A title changed elsewhere doesn't make a step stale, and since an undo proposes only a placement, your handler keeps the new title.

Creates and deletes

Undoing a delete proposes a create that carries the deleted record as change.event. Restore it in your handler: a create with event: null is a new event, as from a range dragged out in a view.

undoable-calendar.tsx
function applyChange(
  meetings: readonly Meeting[],
  change: MeetingChange,
  id = crypto.randomUUID(),
): readonly Meeting[] {
  if (change.cause === "delete") {
    return meetings.filter((meeting) => meeting.id !== change.eventId);
  }
  if (change.proposed.kind !== "timed") return meetings;

  const { start, end } = change.proposed;
  if (change.cause === "create") {
    // Undoing a delete proposes a create with the deleted meeting: bring it back as it was.
    const meeting = change.event ?? { id, title: "New meeting", color: "blue" as const };
    return [...meetings, { ...meeting, start, end }];
  }
  return meetings.map((meeting) =>
    meeting.id === change.eventId ? { ...meeting, start, end } : meeting,
  );
}

The calendar can't tell which event a create became, so the history needs your help to undo one:

  • A restored event is looked for under the ID it had. If you restore records as they were, under their own IDs, there is nothing to do.
  • createdEventId(change, events) returns the event a committed create became, or undefined while your events don't show it yet. The history asks it when it undoes or redoes the step, not when it records it. With it, creates are recorded, and a restored event that got a new ID, as from a server that assigns them, is found under that ID. The other steps for the event follow it to the new ID.
  • An event it can't name leaves the step for another try (unknown-event). If newer events still don't have it, it was deleted somewhere the history didn't see, and the step is dropped as stale.

If your handler can't restore a deleted record, leave deletes out: filter: (change) => change.cause !== "delete".

Recurring events

Changes to occurrences of a recurring event aren't supported yet. Your handler turns such a change into an edit of the series ("this event", "this and following", "all events"), and taking back a split series or a shifted one isn't a change of one event's placement. Leave them out with the recurrence package's isRecurringChange:

import { isRecurringChange } from "@moltenship/calendar-recurrence";

const history = createEventHistory<TeamEvent, string, string>({
  filter: (change) => !isRecurringChange(change),
});

Announcements

With messages, useCalendarHistory speaks each undo and redo outcome through the root's Announcer, such as "Undid the move: Design review is back at Thursday, October 8, 3:00 – 4:00 PM." The packages ship no text: the registry's calendar/announcements.ts has an English set, calendarHistoryAnnouncements, to edit or translate. Each message gets the outcome and the announcement context, with the step's event when the calendar shows it.

Outside React

createEventHistory doesn't use React. Record each result yourself, and pass undo and redo the events the user sees and a function that proposes, such as propose from createEventTransactions. getEvents must return the same snapshot until the events change: the history tells events that haven't caught up with its own saves by that. Pass whenSettled too, a promise for when no change is validating or saving, so an undo waits for a change still saving:

const history = createEventHistory<Meeting, string, never>();
history.record(result, { input: "api" });

await history.undo({
  getEvents: () => visibleEvents(),
  propose: (proposal, detail) => proposeAndRecord(proposal, detail),
  whenSettled: () => changesSettled(),
});

history.subscribe and history.getSnapshot follow the store contract, and history.clear() empties both stacks.

Edit on GitHub

Last updated on

On this page