Using Fabric.js with React is mostly about timing: the canvas exists only after the first render, and the engine must be destroyed on unmount. The fabricjs-document-engine/react entry handles both with a few React hooks.
Loading the live demo. Its code is below.
"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>
);
}Create the canvas
Fabric.js needs a real <canvas> element, so create it in an effect:
import { Canvas } from "fabric";
import { useEffect, useRef, useState } from "react";
export function useFabricCanvas(width: number, height: number) {
const elementRef = useRef<HTMLCanvasElement>(null);
const [canvas, setCanvas] = useState<Canvas | null>(null);
useEffect(() => {
const created = new Canvas(elementRef.current!, { width, height });
setCanvas(created);
return () => {
created.dispose().catch(() => undefined);
};
}, [width, height]);
return { elementRef, canvas };
}Create the engine
useDocumentEngine creates the engine once the canvas exists, and destroys it on unmount. It returns null until then:
import { bindKeyboardShortcuts } from "fabricjs-document-engine";
import {
DocumentEngineProvider,
useDocumentEngine,
} from "fabricjs-document-engine/react";
import { createLocalStorage } from "fabricjs-document-engine/storage";
import { useEffect } from "react";
const storage = createLocalStorage({ prefix: "my-app:" });
export function Editor() {
const { elementRef, canvas } = useFabricCanvas(800, 500);
const engine = useDocumentEngine(canvas, { storage, autosave: true });
useEffect(() => {
if (!engine) return;
engine.load("drawing-1").catch(() => undefined);
return bindKeyboardShortcuts(engine);
}, [engine]);
return (
<DocumentEngineProvider engine={engine}>
<Toolbar />
<canvas ref={elementRef} aria-label="Drawing canvas" />
</DocumentEngineProvider>
);
}Options are read when the engine is created. Define storage outside the component, so it is not recreated on every render.
Build the toolbar
useEngine() reads the engine from the provider, and useDocumentState() re-renders when the save status, history or loading state changes:
import { useDocumentState, useEngine } from "fabricjs-document-engine/react";
function Toolbar() {
const engine = useEngine();
const state = useDocumentState(engine);
if (!engine || !state) return null;
return (
<div role="toolbar">
<button disabled={!state.canUndo} onClick={() => engine.undo()}>
Undo {state.undoLabel}
</button>
<button disabled={!state.canRedo} onClick={() => engine.redo()}>
Redo
</button>
<button disabled={!state.isDirty} onClick={() => engine.save()}>
Save
</button>
<span aria-live="polite">{state.saveStatus}</span>
</div>
);
}Listen to events
useDocumentEvent subscribes with the latest handler, so you never re-subscribe by hand:
import { useDocumentEvent } from "fabricjs-document-engine/react";
useDocumentEvent(engine, "save:error", ({ error }) => {
toast.error(`Could not save: ${error.message}`);
});StrictMode
React StrictMode runs effects twice in development. useDocumentEngine destroys the first engine before creating the second, so only one is alive. Always read the engine from the hook's return value, and this Fabric.js React example works the same in development and production.
For Next.js, see the Next.js guide. Every hook is listed in the React hooks API.