Overview

  • How to add undo/redo functionality to the React Scheduler component from DayPilot Lite for JavaScript.

  • The UndoService keeps a history of all drag and drop actions performed by the user. There is no fixed history limit; changes are kept in memory for the current page session.

  • The full history of actions and the current position are displayed below the Scheduler.

  • This tutorial assumes that you are already familiar with the React Scheduler component. For an introduction, please see React Scheduler with Horizontal Timeline (Open-Source).

How to Use the UndoService to Track React Scheduler Changes

The undo/redo logic is implemented in the UndoService class. The UndoService keeps track of changes and provides an API for retrieving the last action (undo) and replaying the reverted action (redo).

First, you need to create a new instance and save it as undoService in the React Scheduler component:

import { useEffect, useRef, useState } from 'react';
import { DayPilot, DayPilotScheduler } from "@daypilot/daypilot-lite-react";
import {UndoService} from "./UndoService.js";
// ...
const Scheduler = () => {
  // ...
  const [undoService] = useState(() => new UndoService());
  // ...
};
export default Scheduler;

Next, it’s necessary to initialize the UndoService with the initial state so it can calculate changes later. Keep the initial event array stable and call the initialize() method and provide the initial data set:

const [events] = useState(() => [
    {
      id: 1,
      text: "Event 1",
      start: "2027-09-02T00:00:00",
      end: "2027-09-05T00:00:00",
      resource: "A",
      backColor: "#b2e0c9",
      borderColor: "#8cc9a6",
      fontColor: "#333"
    },
    // ...
  ]);
// ...
useEffect(() => {
  undoService.initialize(events);
}, [undoService, events]);

Initializing the service clears its history and resets the position. Call it when loading a new data set, not after each user action.

The last step is to record all changes performed by the user. The UndoService provides add(), update(), and remove() methods. Add them to the React Scheduler event handlers:

  • onTimeRangeSelected: record a new event using UndoService.add()

  • onEventMoved: record an event change using UndoService.update()

  • onEventResized: record an event change using UndoService.update()

  • onClick handler of the active area that adds a delete icon: record a removed event using UndoService.remove()

The UndoService methods accept the changed object as the first parameter. Optionally, you can add a text description with details of the action.

<DayPilotScheduler
  timeHeaders={[{groupBy: "Month"}, {groupBy: "Day", format: "d"}]}
  rowHeaderWidth={100}
  scale="Day"
  days={30}
  startDate="2027-09-01"
  rowMarginTop={2}
  rowMarginBottom={2}
  durationBarVisible={false}
  eventBorderRadius={20}
  onBeforeEventRender={args => {
    args.data.areas = [
      {
        top: 8,
        right: 5,
        width: 20,
        height: 20,
        padding: 2,
        symbol: "./icons/daypilot.svg#x-2",
        style: "cursor: pointer",
        fontColor: "#666",
        toolTip: "Delete",
        onClick: async args => {
          const e = args.source;
          undoService.remove(e.data, "Event removed");
          getScheduler().events.remove(e);
        }
      }
    ];
  }}
  onTimeRangeSelected={async (args) => {
    const modal = await DayPilot.Modal.prompt("Create a new event:", "Event 1");
    getScheduler().clearSelection();
    if (modal.canceled) { return; }

    const e = {
      start: args.start,
      end: args.end,
      id: DayPilot.guid(),
      resource: args.resource,
      text: modal.result
    };
    getScheduler().events.add(e);
    undoService.add(e, "Event added");
  }}
  onEventMoved={(args) => {
    undoService.update(args.e.data, "Event moved");
  }}
  onEventResized={(args) => {
    undoService.update(args.e.data, "Event resized");
  }}
  events={events}
  resources={resources}
  ref={schedulerRef}
/>

Undo and redo apply the saved event data directly to the Scheduler. These operations do not create new history entries. If the user makes a new change after undoing an action, the service discards the remaining redo entries and starts a new branch.

How to Add Undo and Redo Buttons to the React Scheduler

undo redo buttons

The Buttons React component displays the Undo and Redo buttons.

We have wrapped the undo and redo buttons in a standalone React component. It needs to use its own state - sharing the state with the Scheduler component would trigger unnecessary updates of the Scheduler.

import { useState, useEffect } from "react";

const Buttons = ({ service, onUndo, onRedo }) => {
  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);

  useEffect(() => {
    const updateState = () => {
      setCanUndo(service.canUndo);
      setCanRedo(service.canRedo);
    };

    service.watch(updateState);

    return () => {
      service.unwatch(updateState);
    };

  }, [service]);

  const doUndo = () => {
    if (typeof onUndo === "function") {
      onUndo();
    }
  };

  const doRedo = () => {
    if (typeof onRedo === "function") {
      onRedo();
    }
  };

  return (
    <div className={"space"}>
      <button disabled={!canUndo} onClick={doUndo}>Undo</button>
      <button disabled={!canRedo} onClick={doRedo}>Redo</button>
    </div>
  );
};

export default Buttons;

The Buttons component uses three props:

  • service - pass the UndoService instance from the React Scheduler

  • onUndo - define the event handler for the “Undo” button click

  • onRedo - define the event handler for the “Redo” button click

<Buttons service={undoService} onUndo={() => undo()} onRedo={() => redo()}/>

The undo() function (defined in the Scheduler component) performs the “undo” action. It reverts the last operation retrieved from the UndoService using the UndoService.undo() method.

const undo = () => {
  const action = undoService.undo();

  switch (action.type) {
    case "add":
      // added, need to delete now
      getScheduler().events.remove(action.id);
      break;
    case "remove":
      // removed, need to add now
      getScheduler().events.add(action.previous);
      break;
    case "update":
      // updated
      getScheduler().events.update(action.previous);
      break;
    default:
      throw new Error("Unexpected action");
  }
};

The redo() function performs the “redo” action. It gets the next action from the history (it’s the last action that was reverted using the “undo” button) and replays it:

const redo = () => {
  const action = undoService.redo();

  switch (action.type) {
    case "add":
      // added, need to re-add
      getScheduler().events.add(action.current);
      break;
    case "remove":
      // removed, need to remove again
      getScheduler().events.remove(action.id);
      break;
    case "update":
      // updated, use the new version
      getScheduler().events.update(action.current);
      break;
    default:
      throw new Error("Unexpected action");
  }
};

How to Display Undo/Redo History for React Scheduler

undo redo history

To show the state of the history, we’ll use a special History React component. It displays a list of changes recorded in the undo/redo history and the current position. The red marker points to the next action that Redo will replay; when it is below the list, all recorded actions are applied.

import { useState, useEffect } from "react";

const History = ({ service }) => {
  const [position, setPosition] = useState(0);
  const [history, setHistory] = useState([]);

  useEffect(() => {
    const updateState = (args) => {
      setHistory(args.history);
      setPosition(args.position);
    };

    service.watch(updateState);

    return () => {
      service.unwatch(updateState);
    };
  }, [service]);

  const list = history.map((item, i) => {
    const highlighted = position === i;
    const highlightedClass = highlighted ? "highlighted" : "";
    return (
      <div key={i} className={`history-item ${highlightedClass}`}>
        {item.type} - {item.text}
      </div>
    );
  });

  const total = history.length || 0;

  return (
    <div className={"space-20"}>
      <div className={"space"} style={{ color: "gray" }}>
        There are {total} items in the history:
      </div>
      <div className={"history-list"}>
      {list}
      </div>
      <div className={history.length && position === history.length ? "highlighted" : ""}></div>
    </div>
  );
};

export default History;

To provide the History component access to the UndoService instance, you need to pass it using the service attribute:

<History service={undoService}/>

Full Source Code of the Scheduler Component with Undo/Redo Support

Here you can find the full JavaScript source code of the React Scheduler component with Undo/Redo support. The source code of the UndoService class can be found in the attached project (see the top of the article).

import { useEffect, useRef, useState } from 'react';
import { DayPilot, DayPilotScheduler } from "@daypilot/daypilot-lite-react";
import {UndoService} from "./UndoService.js";
import Buttons from "./Buttons.jsx";
import History from "./History.jsx";

const Scheduler = () => {
  const schedulerRef = useRef();
  const [undoService] = useState(() => new UndoService());
  const [events] = useState(() => [
      {
        id: 1,
        text: "Event 1",
        start: "2027-09-02T00:00:00",
        end: "2027-09-05T00:00:00",
        resource: "A",
        backColor: "#b2e0c9",
        borderColor: "#8cc9a6",
        fontColor: "#333"
      },
      {
        id: 2,
        text: "Event 2",
        start: "2027-09-03T00:00:00",
        end: "2027-09-10T00:00:00",
        resource: "C",
        backColor: "#cdebd8",
        borderColor: "#a3d4b8",
        fontColor: "#333"
      },
      {
        id: 3,
        text: "Event 3",
        start: "2027-09-02T00:00:00",
        end: "2027-09-08T00:00:00",
        resource: "D",
        backColor: "#e9f6e8",
        borderColor: "#c8e5c7",
        fontColor: "#333"
      },
      {
        id: 4,
        text: "Event 4",
        start: "2027-09-04T00:00:00",
        end: "2027-09-10T00:00:00",
        resource: "F",
        backColor: "#f4e1d2",
        borderColor: "#e2c4af",
        fontColor: "#333"
      }
    ]);
  const [resources] = useState(() => [
      {name: "Resource A", id: "A"},
      {name: "Resource B", id: "B"},
      {name: "Resource C", id: "C"},
      {name: "Resource D", id: "D"},
      {name: "Resource E", id: "E"},
      {name: "Resource F", id: "F"},
      {name: "Resource G", id: "G"}
    ]);

  useEffect(() => {
    undoService.initialize(events);
  }, [undoService, events]);

  const getScheduler = () => schedulerRef.current?.control;

  const undo = () => {
    const action = undoService.undo();

    switch (action.type) {
      case "add":
        // added, need to delete now
        getScheduler().events.remove(action.id);
        break;
      case "remove":
        // removed, need to add now
        getScheduler().events.add(action.previous);
        break;
      case "update":
        // updated
        getScheduler().events.update(action.previous);
        break;
      default:
        throw new Error("Unexpected action");
    }
  };

  const redo = () => {
    const action = undoService.redo();

    switch (action.type) {
      case "add":
        // added, need to re-add
        getScheduler().events.add(action.current);
        break;
      case "remove":
        // removed, need to remove again
        getScheduler().events.remove(action.id);
        break;
      case "update":
        // updated, use the new version
        getScheduler().events.update(action.current);
        break;
      default:
        throw new Error("Unexpected action");
    }
  };

  return (
    <div>
      <Buttons service={undoService} onUndo={() => undo()} onRedo={() => redo()}/>
      <DayPilotScheduler
        timeHeaders={[{groupBy: "Month"}, {groupBy: "Day", format: "d"}]}
        rowHeaderWidth={100}
        scale="Day"
        days={30}
        startDate="2027-09-01"
        rowMarginTop={2}
        rowMarginBottom={2}
        durationBarVisible={false}
        eventBorderRadius={20}
        onBeforeEventRender={args => {
          args.data.areas = [
            {
              top: 8,
              right: 5,
              width: 20,
              height: 20,
              padding: 2,
              symbol: "./icons/daypilot.svg#x-2",
              style: "cursor: pointer",
              fontColor: "#666",
              toolTip: "Delete",
              onClick: async args => {
                const e = args.source;
                undoService.remove(e.data, "Event removed");
                getScheduler().events.remove(e);
              }
            }
          ];
        }}
        onTimeRangeSelected={async (args) => {
          const modal = await DayPilot.Modal.prompt("Create a new event:", "Event 1");
          getScheduler().clearSelection();
          if (modal.canceled) { return; }
    
          const e = {
            start: args.start,
            end: args.end,
            id: DayPilot.guid(),
            resource: args.resource,
            text: modal.result
          };
          getScheduler().events.add(e);
          undoService.add(e, "Event added");
        }}
        onEventMoved={(args) => {
          undoService.update(args.e.data, "Event moved");
        }}
        onEventResized={(args) => {
          undoService.update(args.e.data, "Event resized");
        }}
        events={events}
        resources={resources}
        ref={schedulerRef}
      />
      <h2>History</h2>
      <History service={undoService}/>
    </div>
  );
};
export default Scheduler;