# React

> Using Fabric.js with React: a complete Fabric.js React example with hooks for the engine, a toolbar built from useDocumentState, and StrictMode-safe cleanup.

Source: https://fabricjs-document-engine.jscrate.dev/docs/frameworks/react
Last updated: 2026-09-28

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.

```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>
  );
}
```

## Create the canvas

Fabric.js needs a real `<canvas>` element, so create it in an effect:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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](https://fabricjs-document-engine.jscrate.dev/docs/frameworks/next-js). Every hook is listed in the [React hooks API](https://fabricjs-document-engine.jscrate.dev/docs/api/react-hooks).
