# Undo and redo

> Add Fabric.js undo redo to your editor: one step per user action, transactions for code changes, keyboard shortcuts and a history that stays fast.

Source: https://fabricjs-document-engine.jscrate.dev/docs/guides/undo-redo
Last updated: 2026-09-28

Fabric.js has no undo built in. With this package, Fabric.js undo redo is on as soon as you create the engine. Every user action becomes one step, and you call `undo()` and `redo()`.

```tsx
"use client";

import { bindKeyboardShortcuts } from "fabricjs-document-engine";
import {
  useDocumentEngine,
  useDocumentState,
} from "fabricjs-document-engine/react";
import {
  CircleIcon,
  PaletteIcon,
  Redo2Icon,
  SquareIcon,
  Undo2Icon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef } from "react";

import {
  COLORS,
  DemoCanvas,
  DemoFrame,
  newShape,
  seedShapes,
  Status,
  Toolbar,
  ToolButton,
} from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";

export default function UndoRedoDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const engine = useDocumentEngine(canvas);
  const state = useDocumentState(engine);
  const demoRef = useRef<HTMLDivElement>(null);

  // Start from a small drawing, with an empty history.
  useEffect(() => {
    if (!engine) return;
    engine.canvas.add(...seedShapes());
    engine.clearHistory();
  }, [engine]);

  // Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z, while focus is inside this demo.
  useEffect(() => {
    if (!engine || !demoRef.current) return;
    return bindKeyboardShortcuts(engine, { target: demoRef.current });
  }, [engine]);

  function recolor() {
    const target = canvas?.getActiveObject() ?? canvas?.getObjects().at(-1);
    if (!engine || !target) return;

    const next =
      COLORS[(COLORS.indexOf(String(target.fill)) + 1) % COLORS.length];
    // Fabric fires no event for `set`, so record it as one labelled step.
    engine.transaction("Recolor", () => target.set("fill", next));
    canvas?.requestRenderAll();
  }

  return (
    <DemoFrame ref={demoRef} tabIndex={-1}>
      <DemoCanvas elementRef={elementRef} label={t("canvas")} />

      <Toolbar>
        <ToolButton
          icon={SquareIcon}
          label={t("square")}
          onClick={() => canvas?.add(newShape("square"))}
        />
        <ToolButton
          icon={CircleIcon}
          label={t("circle")}
          onClick={() => canvas?.add(newShape("circle"))}
        />
        <ToolButton icon={PaletteIcon} label={t("recolor")} onClick={recolor} />
        <span className="ml-auto flex gap-1.5">
          <ToolButton
            icon={Undo2Icon}
            label={t("undo")}
            iconOnly
            disabled={!state?.canUndo}
            onClick={() => void engine?.undo()}
          />
          <ToolButton
            icon={Redo2Icon}
            label={t("redo")}
            iconOnly
            disabled={!state?.canRedo}
            onClick={() => void engine?.redo()}
          />
        </span>
      </Toolbar>

      <Status tone={state?.canUndo ? "ok" : "idle"}>
        {state?.canUndo
          ? t("undoRedo.steps", {
              count: engine?.getHistory().undo.length ?? 0,
            })
          : t("undoRedo.empty")}
      </Status>
    </DemoFrame>
  );
}
```

```tsx
import { Canvas } from "fabric";
import { useLayoutEffect, useRef, useState } from "react";

/**
 * The page every demo draws on. The document keeps this size; CSS scales the
 * canvas down to fit its card, so saved documents and exports never depend on
 * the screen.
 */
export const PAGE = { width: 480, height: 300 };

// Selection handles in the site's style. Fabric does not save these.
const SELECTION = {
  borderColor: "#6f00ff",
  borderScaleFactor: 1.5,
  borderOpacityWhenMoving: 0.5,
  cornerStyle: "circle",
  cornerSize: 11,
  cornerColor: "#ffffff",
  cornerStrokeColor: "#6f00ff",
  transparentCorners: false,
} as const;

export function styleSelection(canvas: Canvas) {
  canvas.set({
    selectionColor: "rgba(111, 0, 255, 0.06)",
    selectionBorderColor: "#6f00ff",
    selectionLineWidth: 1,
  });
  const style = () => canvas.getActiveObject()?.set(SELECTION);
  canvas.on("selection:created", style);
  canvas.on("selection:updated", style);
}

/**
 * Creates a Fabric.js canvas once the `<canvas>` element exists, and disposes
 * it on unmount. The engine is created from the returned `canvas`.
 */
export function useFabricCanvas({ width, height } = PAGE) {
  const elementRef = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<Canvas | null>(null);

  // A layout effect creates the canvas before the first paint.
  useLayoutEffect(() => {
    const created = new Canvas(elementRef.current!, {
      width,
      height,
      backgroundColor: "#ffffff",
    });
    styleSelection(created);
    setCanvas(created);

    return () => {
      // Dispose after the engine's own cleanup, so its last recovery copy
      // still sees the objects.
      setTimeout(() => created.dispose().catch(() => undefined));
    };
  }, [width, height]);

  return { elementRef, canvas };
}
```

```tsx
import { Circle, type FabricObject, Rect, Triangle } from "fabric";
import type { LucideIcon } from "lucide-react";

import { cn } from "@/lib/utils";

import { PAGE } from "./use-fabric-canvas";

export const COLORS = ["#f2836f", "#5fe6c4", "#7ad9ec", "#6f00ff", "#ffd666"];

// Demo shapes are placed by their top-left corner, like a design tool.
const CORNER = { originX: "left", originY: "top" } as const;

/** A new shape at a random spot on the page. */
export function newShape(kind: "square" | "circle", fill?: string) {
  const left = 40 + Math.random() * (PAGE.width - 160);
  const top = 30 + Math.random() * (PAGE.height - 120);
  const color = fill ?? COLORS[Math.floor(Math.random() * COLORS.length)];
  return kind === "square"
    ? new Rect({
        ...CORNER,
        left,
        top,
        width: 88,
        height: 64,
        rx: 10,
        ry: 10,
        fill: color,
      })
    : new Circle({ ...CORNER, left, top, radius: 34, fill: color });
}

/** A small composed drawing, so no demo starts on an empty page. */
export function seedShapes(): FabricObject[] {
  return [
    new Rect({
      ...CORNER,
      left: 48,
      top: 56,
      width: 176,
      height: 124,
      rx: 16,
      ry: 16,
      fill: COLORS[4],
    }),
    new Circle({ ...CORNER, left: 268, top: 44, radius: 58, fill: COLORS[0] }),
    new Rect({
      ...CORNER,
      left: 256,
      top: 196,
      width: 176,
      height: 52,
      rx: 26,
      ry: 26,
      fill: COLORS[1],
    }),
    new Triangle({
      ...CORNER,
      left: 96,
      top: 196,
      width: 72,
      height: 60,
      fill: COLORS[3],
    }),
  ];
}

export function DemoFrame({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      className={cn(
        "flex w-full max-w-md flex-col gap-3 text-left outline-none",
        className
      )}
      {...props}
    />
  );
}

/**
 * Scales the fixed-size Fabric canvas to the frame's width. The rules are
 * `!important` because Fabric writes its sizes as inline styles.
 */
export function DemoCanvas({
  elementRef,
  label,
  page = PAGE,
  children,
}: {
  elementRef: React.Ref<HTMLCanvasElement>;
  label: string;
  page?: { width: number; height: number };
  children?: React.ReactNode;
}) {
  return (
    <div
      className="relative overflow-hidden rounded-lg border bg-white shadow-xs [&_.canvas-container]:aspect-(--page)! [&_.canvas-container]:h-auto! [&_.canvas-container]:w-full! [&_canvas]:h-full! [&_canvas]:w-full!"
      style={
        { "--page": `${page.width} / ${page.height}` } as React.CSSProperties
      }
    >
      <div>
        <canvas ref={elementRef} aria-label={label} />
      </div>
      {children}
    </div>
  );
}

export function Toolbar({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      className={cn("flex flex-wrap items-center gap-1.5", className)}
      {...props}
    />
  );
}

export function ToolButton({
  icon: Icon,
  label,
  iconOnly = false,
  primary = false,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  icon: LucideIcon;
  label: string;
  iconOnly?: boolean;
  primary?: boolean;
}) {
  return (
    <button
      type="button"
      title={label}
      aria-label={iconOnly ? label : undefined}
      className={cn(
        "inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md border px-2.5 text-[13px] font-medium whitespace-nowrap transition-colors disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
        primary
          ? "border-transparent bg-foreground text-background hover:bg-foreground/85"
          : "bg-background hover:bg-muted",
        iconOnly && "w-8 px-0",
        className
      )}
      {...props}
    >
      <Icon aria-hidden />
      {!iconOnly && <span className="truncate">{label}</span>}
    </button>
  );
}

const TONES = {
  idle: "bg-zinc-400",
  busy: "bg-amber-400",
  ok: "bg-emerald-500",
  error: "bg-red-500",
};

/** One line of status text. Its height never changes, so the card never jumps. */
export function Status({
  tone = "idle",
  children,
}: {
  tone?: keyof typeof TONES;
  children: React.ReactNode;
}) {
  return (
    <p
      role="status"
      className="flex h-5 min-w-0 items-center gap-2 text-[13px] text-muted-foreground"
    >
      <span
        aria-hidden
        className={cn("size-1.5 shrink-0 rounded-full", TONES[tone])}
      />
      <span className="truncate">{children}</span>
    </p>
  );
}
```

## What is recorded for you

The engine listens to Fabric.js events and records:

- adding and deleting objects (several changes in the same tick become one step),
- moving, resizing and rotating with the pointer (Fabric's `object:modified`),
- finished text editing.

Each step gets a label, such as `Add rect` or `Move circle`, that you can show on the button.

```ts
await engine.undo();
await engine.redo();

engine.canUndo();
engine.getHistory(); // { undo: ["Move circle", "Add rect"], redo: [] }
```

Calls run in order, so pressing undo three times fast undoes three steps.

## Changes your code makes

Fabric.js fires no event for `object.set("fill", "red")` or `canvas.bringObjectForward(object)`. Wrap such changes in a transaction, so they become one labelled step:

```ts
engine.transaction("Arrange furniture", () => {
  chair.set({ left: 120, top: 80 });
  table.set("fill", "oak");
  canvas.bringObjectToFront(table);
});
```

Or make the change first and call `commit`:

```ts
canvas.sendObjectBackwards(rug);
engine.commit("Send rug backwards");
```

- Transactions can be nested. The outermost label is used.
- They can be async: `await engine.transaction("Import", async () => { /* ... */ })`.
- Grouping and ungrouping are changes to the object list. Do the remove and the add in one transaction to get one undo step.

## Keyboard shortcuts

```ts
import { bindKeyboardShortcuts } from "fabricjs-document-engine";

const unbind = bindKeyboardShortcuts(engine);
```

Ctrl+Z or Cmd+Z undoes. Ctrl+Shift+Z, Cmd+Shift+Z and Ctrl+Y redo. The shortcuts are ignored while the user types in an input, a textarea, a contenteditable element or Fabric.js text, so native text undo keeps working there. Pass `{ target: element }` to listen on one element instead of `window`.

## Toolbar state

```ts
engine.on("history:change", ({ canUndo, canRedo, undoLabel }) => {
  undoButton.disabled = !canUndo;
  redoButton.disabled = !canRedo;
  undoButton.textContent = undoLabel ? `Undo ${undoLabel}` : "Undo";
});
```

## Keep ids, not objects

Undo and redo rebuild the changed objects from their saved state. They come back as new Fabric.js instances with the same ids. Keep the id, and look the object up again:

```ts
const id = shape.get("id") as string;
await engine.undo();
const current = engine.getObjectById(id);
```

## Fabric.js history size

Each step stores only the objects that changed, so a step stays small in large drawings. By default the Fabric.js history keeps 100 steps and at most 64 MB. The oldest steps are dropped first:

```ts
createDocumentEngine({
  canvas,
  history: { limit: 50, maxBytes: 32 * 1024 * 1024 },
});
```

Fabric.js undo redo covers the recent edits of this session. To keep states for later, use [versions](https://fabricjs-document-engine.jscrate.dev/docs/guides/versions).
