React annotation state management with Fabric.js canvas events, selection, autosave, undo/redo, object updates, and saved annotation data.

State Management in React Annotation and Canvas Tools: Managing Fabric.js & Canvas Events

Building an annotation tool in React looks straightforward until the canvas becomes interactive.

At first, the application may only need to:

  • load an image;
  • draw a rectangle;
  • select an annotation;
  • change a label;
  • save the result.

Then the requirements grow.

Users need to drag and resize boxes. They want polygon annotations, zoom, pan, keyboard shortcuts, undo and redo, multi-selection, autosave, task navigation, QC status, annotation metadata, and eventually hundreds or thousands of objects on the same canvas.

This is where state management becomes more important than drawing.

The difficult question is not:

How do I create a rectangle with Fabric.js?

It is:

Who owns the rectangle’s state after it exists?

React has state.

Fabric.js has its own mutable object model.

The browser canvas has pointer events.

Your backend has persisted annotation data.

And all four can describe the same annotation differently if the architecture is not carefully designed.

A production annotation tool therefore needs a clear boundary:

React
│
├── UI state
├── task state
├── annotation domain state
└── workflow state
       │
       ▼
Integration Layer
       │
       ▼
Fabric.js
│
├── Canvas instance
├── Fabric objects
├── selection
├── live transforms
└── viewport
       │
       ▼
HTML Canvas

The most important rule is simple:

Do not make React and Fabric.js compete to be the owner of every live canvas property.

This guide explains how to structure state, initialize Fabric.js correctly inside React, manage canvas events, prevent duplicate listeners, handle selection and object modifications, build undo/redo, serialize annotations safely, manage zoom coordinates, implement autosave, and keep large annotation tools responsive.


Table of Contents

Why React and Fabric.js Need Different State Responsibilities

React is declarative.

You describe what the UI should represent:

selected label = "vehicle"
active tool = "bounding-box"
task status = "in_progress"
selected annotation IDs = ["ann-42"]

React then renders the appropriate UI.

Fabric.js is primarily imperative.

You create an object:

const rect = new Rect({
  left: 100,
  top: 80,
  width: 200,
  height: 120
});

canvas.add(rect);

The user can then drag that rectangle.

Fabric modifies the object’s coordinates directly while the interaction occurs.

That difference matters.

If you try to force React to update the complete Fabric object model on every mouse movement, you create unnecessary renders and synchronization problems.

If you put everything inside Fabric objects instead, the surrounding React UI becomes difficult to keep consistent.

A better division is:

StateRecommended Owner
Active annotation toolReact
Selected label/categoryReact
Current task IDReact
QC statusReact
Dirty/saved statusReact
Selected annotation IDsReact
Persisted annotation recordsReact/store/domain layer
Fabric Canvas instanceuseRef
Live Fabric objectsFabric.js
Dragging/resizing in progressFabric.js/ref
Viewport transformFabric.js, optionally mirrored when UI needs it
Current pointer positionUsually Fabric/ref
Undo/redo historyDomain/history layer
Saved annotation documentBackend

The distinction between React state and refs is particularly relevant here. React recommends refs for mutable values that do not need to trigger rendering and describes refs as useful when communicating with external systems. Changing a ref does not trigger a component render.

Fabric.js is exactly this kind of external imperative system.


Fabric.js Is an External System from React’s Perspective

Fabric.js maintains an interactive object model on top of the HTML Canvas API.

It manages:

  • objects;
  • selections;
  • transforms;
  • hit detection;
  • drawing;
  • event handling;
  • viewport transforms;
  • serialization;
  • rendering.

The current Fabric.js documentation describes Canvas as the interactive canvas implementation capable of selection, dragging, scaling, rotation, drawing and object serialization.

React should therefore integrate with Fabric similarly to how it integrates with:

maps
video players
chart libraries
WebSocket connections
third-party widgets

React’s useEffect documentation specifically recommends Effects when synchronizing components with systems outside React, with corresponding cleanup when the component unmounts or dependencies change.

That gives us the foundation for the canvas component.


Installing Fabric.js

For a modern React application:

npm install fabric

Modern Fabric.js uses named imports.

For example:

import {
  Canvas,
  Rect,
  FabricObject
} from "fabric";

Older tutorials often use:

import { fabric } from "fabric";

That pattern belongs to older Fabric versions.

Fabric 6 moved to named ES module imports, and the current Fabric.js release line is 7.x.

This article uses the modern API style.


The Wrong Way: Put the Fabric Canvas in React State

Avoid:

const [canvas, setCanvas] =
  useState(null);

The Fabric canvas instance is a mutable class instance.

React does not need to re-render every time something inside that object changes.

Use a ref instead:

const fabricCanvasRef =
  useRef(null);

Changing:

fabricCanvasRef.current

does not trigger React rendering.

That is exactly what we want.


The Basic React + Fabric.js Canvas Component

A clean starting point is:

import {
  useEffect,
  useRef
} from "react";

import {
  Canvas
} from "fabric";


export function AnnotationCanvas() {

  const elementRef =
    useRef<HTMLCanvasElement | null>(
      null
    );

  const fabricRef =
    useRef<Canvas | null>(
      null
    );


  useEffect(() => {

    const element =
      elementRef.current;

    if (!element) {
      return;
    }


    const canvas =
      new Canvas(
        element,
        {
          selection: true,
          preserveObjectStacking: true
        }
      );


    fabricRef.current =
      canvas;


    return () => {

      if (
        fabricRef.current === canvas
      ) {
        fabricRef.current = null;
      }

      void canvas
        .dispose()
        .catch((error) => {
          console.error(
            "Fabric canvas cleanup failed",
            error
          );
        });
    };

  }, []);


  return (
    <canvas ref={elementRef} />
  );
}

The important design is:

React renders <canvas>
      ↓
useEffect creates Fabric Canvas
      ↓
useRef stores Fabric instance
      ↓
cleanup disposes Canvas

Current Fabric.js Canvas.dispose() is asynchronous and returns a Promise<boolean> while it waits for pending rendering to settle before destroying the canvas.


Why Cleanup Matters in React Strict Mode

React development builds may run an additional setup-and-cleanup cycle when Strict Mode is enabled.

This is intentional.

React uses that extra cycle to reveal Effects whose setup is not correctly reversed by cleanup.

Without proper Fabric cleanup, you can accidentally create:

Canvas instance 1
Canvas instance 2
duplicate event listeners
duplicate pointer handlers
strange selection behavior

and then spend hours debugging what appears to be a Fabric problem.

The Effect should conceptually always be:

Create
↓
Subscribe
↓
Use
↓
Unsubscribe
↓
Dispose

The Second Major Rule: Don’t Duplicate Every Fabric Property in React

Consider this React state:

const [object, setObject] =
  useState({
    left: 10,
    top: 20,
    width: 300,
    height: 100,
    scaleX: 1,
    scaleY: 1,
    angle: 0
  });

Then suppose Fabric stores the same values internally.

When a user drags the object, which copy is authoritative?

React copy?

or

Fabric copy?

If React updates slightly later:

Fabric object → x = 150

React state → x = 142

and an Effect subsequently pushes React state back to Fabric, the annotation may jump backwards.

That is a synchronization loop.

For interactive canvas applications, I prefer this rule:

During a gesture

Fabric owns live geometry.

When the gesture finishes

Commit the final geometry into the application/domain state.

That produces:

mouse down
    ↓
Fabric interaction
    ↓
mouse move
    ↓
Fabric interaction
    ↓
mouse up / object:modified
    ↓
commit final annotation state
    ↓
React/store/backend

You avoid forcing React through dozens or hundreds of updates during one drag.


Use React State for Semantic State

React state is ideal for information that affects surrounding UI.

For example:

type Tool =
  | "select"
  | "box"
  | "polygon"
  | "pan";


type AnnotationUIState = {

  tool: Tool;

  selectedIds: string[];

  selectedLabel:
    string | null;

  dirty: boolean;

  revision: number;
};

This state directly affects components such as:

Toolbar
Properties panel
Label selector
Save status
Undo button
QC controls

Changing these values should re-render React components.


Use useReducer Once Annotation State Becomes Complex

An annotation tool often starts with several independent useState() calls:

const [tool, setTool] = useState("select");
const [selectedIds, setSelectedIds] = useState([]);
const [annotations, setAnnotations] = useState([]);
const [dirty, setDirty] = useState(false);
const [revision, setRevision] = useState(0);

This is fine initially.

But annotation changes tend to become actions:

annotation added
annotation moved
annotation deleted
label changed
selection changed
task loaded
undo
redo
save completed

At that point, a reducer can make state transitions easier to reason about.

React recommends reducers when state update logic becomes distributed across many event handlers and emphasizes that reducers should remain pure.

For example:

type Annotation = {

  id: string;

  type: "box";

  label: string;

  left: number;

  top: number;

  width: number;

  height: number;

  scaleX: number;

  scaleY: number;

  angle: number;
};


type State = {

  annotations:
    Record<string, Annotation>;

  selectedIds:
    string[];

  tool:
    "select" | "box" | "pan";

  dirty:
    boolean;

  revision:
    number;
};

Actions:

type Action =

  | {
      type: "annotationAdded";
      annotation: Annotation;
    }

  | {
      type: "annotationUpdated";
      annotation: Annotation;
    }

  | {
      type: "annotationRemoved";
      id: string;
    }

  | {
      type: "selectionChanged";
      ids: string[];
    }

  | {
      type: "toolChanged";
      tool: State["tool"];
    };

Reducer:

function annotationReducer(
  state: State,
  action: Action
): State {

  switch (action.type) {

    case "annotationAdded":

      return {
        ...state,

        annotations: {
          ...state.annotations,

          [action.annotation.id]:
            action.annotation
        },

        dirty: true,

        revision:
          state.revision + 1
      };


    case "annotationUpdated":

      return {
        ...state,

        annotations: {
          ...state.annotations,

          [action.annotation.id]:
            action.annotation
        },

        dirty: true,

        revision:
          state.revision + 1
      };


    case "annotationRemoved": {

      const annotations = {
        ...state.annotations
      };

      delete annotations[action.id];

      return {
        ...state,
        annotations,
        dirty: true,
        revision:
          state.revision + 1
      };
    }


    case "selectionChanged":

      return {
        ...state,
        selectedIds:
          action.ids
      };


    case "toolChanged":

      return {
        ...state,
        tool:
          action.tool
      };


    default:

      return state;
  }
}

Notice what the reducer does not contain:

canvas.add()
canvas.remove()
HTTP request
localStorage write
Fabric object mutation

A reducer should remain pure.


Store the Current Tool in State and a Ref

There is one subtle problem with canvas event handlers.

Suppose:

const [tool, setTool] =
  useState("select");

A Fabric event listener created once may close over an old value of tool.

One practical approach is to keep the UI value in state:

const [tool, setTool] =
  useState("select");

and mirror the latest value into a ref for imperative event handlers:

const toolRef =
  useRef(tool);


useEffect(() => {

  toolRef.current = tool;

}, [tool]);

Then a Fabric pointer handler can read:

toolRef.current

without re-registering every listener whenever the toolbar changes.

This is a good example of state and refs serving different purposes.


Fabric Events You Actually Need in Annotation Tools

Fabric exposes many events.

A common mistake is subscribing to almost all of them.

Typical useful annotation events include:

EventTypical Purpose
selection:createdUpdate selected annotation UI
selection:updatedUpdate multi-selection
selection:clearedClear properties panel
object:modifiedCommit completed transform
object:addedObserve externally created objects if necessary
object:removedObserve removal if necessary
path:createdCapture completed freehand annotation
mouse:downStart custom drawing
mouse:moveLive custom interaction
mouse:upFinish custom interaction
mouse:wheelZoom behavior

Fabric currently exposes both low-level pointer events and higher-level selection/object events.

Fabric’s own event guide also makes an important recommendation: do not use Fabric’s event system as the internal message bus for your entire application. If your own code causes an action, it is usually clearer to call the required application logic directly.

That advice is particularly valuable in React applications.


Register Fabric Events Once

A good pattern is:

useEffect(() => {

  const canvas =
    fabricRef.current;

  if (!canvas) {
    return;
  }


  const syncSelection = () => {

    const ids =
      canvas
        .getActiveObjects()
        .map(
          object =>
            object.annotationId
        )
        .filter(
          (
            id
          ): id is string =>
            Boolean(id)
        );


    dispatch({
      type: "selectionChanged",
      ids
    });
  };


  const disposeCreated =
    canvas.on(
      "selection:created",
      syncSelection
    );


  const disposeUpdated =
    canvas.on(
      "selection:updated",
      syncSelection
    );


  const disposeCleared =
    canvas.on(
      "selection:cleared",
      () => {

        dispatch({
          type: "selectionChanged",
          ids: []
        });
      }
    );


  return () => {

    disposeCreated();

    disposeUpdated();

    disposeCleared();
  };

}, []);

Current Fabric event subscriptions return disposer functions, which is convenient for React Effect cleanup. Fabric warns against indiscriminately calling off() without the original listener because doing so can remove other listeners, including listeners Fabric itself relies on.


Don’t Call off("event") as a Blanket Cleanup Strategy

Avoid:

canvas.off("mouse:down");
canvas.off("object:modified");

That can remove listeners you did not create.

Prefer:

const dispose =
  canvas.on(
    "object:modified",
    handler
  );


return () => {
  dispose();
};

This scales much better as your annotation tool gains more behavior.


Selection State Should Be IDs, Not Fabric Objects

Avoid putting Fabric instances into React state:

setSelectedObjects(
  canvas.getActiveObjects()
);

Those are mutable external objects.

Instead:

setSelectedIds(
  canvas
    .getActiveObjects()
    .map(
      object =>
        object.annotationId
    )
);

Then React operates on:

["annotation-1", "annotation-8"]

rather than mutable class instances.

This keeps React state serializable and easier to debug.


Give Every Annotation a Stable ID

Canvas objects need a stable connection to your application model.

Never depend on:

canvas object index

as the annotation identity.

Objects can be reordered.

Instead:

annotation-2c781e...

should exist independently of stacking order.

Fabric supports custom properties, and its documentation specifically gives IDs and names as typical examples.

For TypeScript:

import {
  FabricObject
} from "fabric";


declare module "fabric" {

  interface FabricObject {

    annotationId?: string;

    annotationLabel?: string;
  }


  interface SerializedObjectProps {

    annotationId?: string;

    annotationLabel?: string;
  }
}


FabricObject.customProperties = [
  "annotationId",
  "annotationLabel"
];

Now:

rect.annotationId =
  crypto.randomUUID();

rect.annotationLabel =
  "vehicle";

and the custom fields can participate in Fabric serialization.


Don’t Turn Fabric Objects into Your Entire Application Database

This is an important architectural boundary.

It is tempting to attach everything to a Fabric object:

taskId
raterEmail
QC status
review comments
createdBy
workflow stage
backend revision
model confidence
audit log

Don’t.

Fabric itself recommends that custom canvas properties remain relevant to rendering or canvas behavior and warns against using Fabric objects as the application’s general data store.

A Fabric object may reasonably contain:

annotationId
label
geometry-related metadata
visual style
canvas behavior flags

Your application model can contain:

annotationId
taskId
label
review status
annotator
confidence
comments
timestamps
workflow state

The shared key is:

annotationId

Convert Fabric Objects into Domain Records

Create an adapter function.

For example:

import {
  FabricObject
} from "fabric";


function toAnnotation(
  object: FabricObject
): Annotation {

  if (!object.annotationId) {

    throw new Error(
      "Annotation is missing ID"
    );
  }


  return {

    id:
      object.annotationId,

    type:
      "box",

    label:
      object.annotationLabel ??
      "unlabeled",

    left:
      object.left,

    top:
      object.top,

    width:
      object.width,

    height:
      object.height,

    scaleX:
      object.scaleX,

    scaleY:
      object.scaleY,

    angle:
      object.angle
  };
}

Now Fabric does not leak through every layer of your application.


Commit Geometry on object:modified

Fabric generates high-frequency transform events while an object is moving, rotating or scaling.

For example:

object:moving
object:scaling
object:rotating

can fire repeatedly during a single gesture.

object:modified represents the completion of the transform.

Fabric’s documentation distinguishes these continuous events from end-of-action events.

For application state:

const disposeModified =
  canvas.on(
    "object:modified",
    ({ target }) => {

      if (!target) {
        return;
      }


      dispatch({

        type:
          "annotationUpdated",

        annotation:
          toAnnotation(target)
      });
    }
  );

This gives you approximately:

dragging for 2 seconds

object:moving:
perhaps dozens/hundreds of events

React/domain update:
1

rather than re-rendering the annotation UI on every pixel movement.


When object:moving Is Appropriate

High-frequency events are still useful when you genuinely need live feedback.

Examples:

coordinate display
snap guides
alignment rulers
collision feedback
live measurement

But that does not mean every event should call:

setState(...)

A better approach is often:

high-frequency temporary value
→ ref / Fabric rendering

committed value
→ React state

If React must render live information, throttle or coalesce the updates.


Building a Bounding Box Tool

A box tool can use Fabric’s pointer events.

In Fabric 7, pointer events expose both:

scenePoint
viewportPoint

The old getPointer() API has been removed; Fabric now recommends scene and viewport coordinates explicitly.

For annotations, scene coordinates are normally what you want.

They represent positions in the canvas scene rather than the transformed viewport.

A simplified box tool:

import {
  Rect
} from "fabric";


const drawingRef =
  useRef<{
    startX: number;
    startY: number;
    rect: Rect;
  } | null>(null);

Mouse down:

const disposeDown =
  canvas.on(
    "mouse:down",
    (event) => {

      if (
        toolRef.current !==
        "box"
      ) {
        return;
      }


      const point =
        event.scenePoint;


      const rect =
        new Rect({

          left:
            point.x,

          top:
            point.y,

          width:
            0,

          height:
            0,

          fill:
            "rgba(0,0,0,0)",

          stroke:
            "#00aaff",

          strokeWidth:
            2
        });


      rect.annotationId =
        crypto.randomUUID();


      canvas.add(rect);


      drawingRef.current = {

        startX:
          point.x,

        startY:
          point.y,

        rect
      };
    }
  );

Mouse move:

const disposeMove =
  canvas.on(
    "mouse:move",
    (event) => {

      const drawing =
        drawingRef.current;

      if (!drawing) {
        return;
      }


      const point =
        event.scenePoint;


      const left =
        Math.min(
          drawing.startX,
          point.x
        );

      const top =
        Math.min(
          drawing.startY,
          point.y
        );

      const width =
        Math.abs(
          point.x -
          drawing.startX
        );

      const height =
        Math.abs(
          point.y -
          drawing.startY
        );


      drawing.rect.set({
        left,
        top,
        width,
        height
      });


      canvas.requestRenderAll();
    }
  );

Mouse up:

const disposeUp =
  canvas.on(
    "mouse:up",
    () => {

      const drawing =
        drawingRef.current;

      if (!drawing) {
        return;
      }


      drawing.rect.setCoords();


      dispatch({

        type:
          "annotationAdded",

        annotation:
          toAnnotation(
            drawing.rect
          )
      });


      drawingRef.current =
        null;
    }
  );

Fabric’s current object API notes that changing geometry through set() does not automatically update control coordinates; call setCoords() when needed after programmatic geometric changes.


Scene Coordinates vs. Viewport Coordinates

This becomes critical once zoom and pan are added.

Imagine an annotation stored at:

x = 100
y = 100

The user zooms to:

200%

The rectangle may appear on-screen around:

x = 200
y = 200

but its annotation coordinates should still remain:

100, 100

This is why Fabric 7’s distinction is useful.

scenePoint

Position in the logical canvas scene.

Use it for:

annotation geometry
object creation
measurement
stored coordinates

viewportPoint

Position relative to the current viewport.

Use it for:

screen overlays
tooltips
context menus
cursor-aligned UI

Mixing these coordinate systems is a common source of annotation drift after zooming.


Zoom and Pan Are View State, Not Annotation Data

Do not permanently modify annotation geometry merely because the user zoomed in.

Conceptually:

Annotation:

x = 100
y = 200
width = 80
height = 60

and:

Viewport:

zoom = 2.0
panX = -300
panY = -100

are different things.

Keep them separate.

Fabric’s viewport transform exists specifically for this purpose.


Avoid React State Updates on Every Pointer Movement

This is one of the biggest performance mistakes in React canvas tools.

Avoid:

canvas.on(
  "mouse:move",
  (event) => {

    setMousePosition({
      x: event.scenePoint.x,
      y: event.scenePoint.y
    });
  }
);

This may cause a React component tree update for every pointer movement.

Instead, ask:

Does React actually need this value?

If not:

mousePositionRef.current = {
  x: event.scenePoint.x,
  y: event.scenePoint.y
};

If the UI does need it, consider throttling updates using:

requestAnimationFrame

or a reasonable interval.

The canvas should not turn React into a pointer-event rendering engine.


Do Not Rebuild the Canvas After Every State Change

Another problematic pattern is:

useEffect(() => {

  canvas.clear();

  annotations.forEach(
    annotation => {
      canvas.add(
        createFabricObject(
          annotation
        )
      );
    }
  );

}, [annotations]);

Every annotation update now causes:

clear canvas
↓
destroy visual object state
↓
recreate all objects
↓
re-render

For a canvas with 1,000 annotations, that becomes expensive very quickly.

Instead, update the specific object:

annotation-73 changed
        ↓
find Fabric object ann-73
        ↓
update ann-73

not:

annotation-73 changed
        ↓
recreate 1,000 objects

Maintain an Object Lookup Map

For larger tools, maintain an index.

const objectMapRef =
  useRef<
    Map<string, FabricObject>
  >(
    new Map()
  );

When creating an object:

objectMapRef.current.set(
  annotation.id,
  object
);

When deleting:

objectMapRef.current.delete(
  annotation.id
);

Now an external update can find an object directly:

const object =
  objectMapRef.current.get(
    annotation.id
  );

instead of repeatedly scanning every canvas object.


Prevent State Synchronization Loops

Suppose:

Fabric object changes
      ↓
React updates model
      ↓
Effect sees model update
      ↓
Fabric object updated
      ↓
Fabric event fires
      ↓
React updates model again

You have created a loop.

One useful guard is:

const applyingExternalStateRef =
  useRef(false);

When synchronizing external/backend state into Fabric:

applyingExternalStateRef.current =
  true;

try {

  object.set({
    left:
      annotation.left,

    top:
      annotation.top
  });

  object.setCoords();

  canvas.requestRenderAll();

} finally {

  applyingExternalStateRef.current =
    false;
}

Then:

canvas.on(
  "object:modified",
  ({ target }) => {

    if (
      applyingExternalStateRef.current
    ) {
      return;
    }

    // commit user-originated change
  }
);

Even better, wherever possible, distinguish between:

commands your application explicitly performs

and:

interactions Fabric performs for the user

Fabric’s own documentation recommends direct functions for behavior your application controls instead of routing everything through generic events.


Use Command Functions for Application-Initiated Changes

For example:

function deleteAnnotation(
  id: string
) {

  const canvas =
    fabricRef.current;

  const object =
    objectMapRef.current.get(id);


  if (
    !canvas ||
    !object
  ) {
    return;
  }


  canvas.remove(object);

  objectMapRef.current.delete(id);


  dispatch({
    type:
      "annotationRemoved",

    id
  });


  pushHistory({
    type:
      "annotationRemoved",

    id
  });
}

Now the deletion logic is explicit.

You don’t need:

canvas.remove()
→ object:removed
→ event handler
→ reducer
→ history
→ autosave

for actions your own code initiated.


Programmatic Selection Should Update React Explicitly

Fabric exposes:

canvas.setActiveObject()
canvas.getActiveObject()
canvas.getActiveObjects()
canvas.discardActiveObject()

The current API provides both single- and multi-selection accessors.

If your side panel selects an annotation:

function selectAnnotation(
  id
) {

  const canvas =
    fabricRef.current;

  const object =
    objectMapRef.current.get(id);


  if (
    !canvas ||
    !object
  ) {
    return;
  }


  canvas.setActiveObject(
    object
  );

  canvas.requestRenderAll();


  dispatch({
    type:
      "selectionChanged",

    ids: [id]
  });
}

Again, don’t force your own application command through an unnecessarily complicated event chain.


Saving Canvas State: Fabric JSON vs. Annotation Data

Fabric supports:

const json =
  canvas.toJSON();

and asynchronous restoration with:

await canvas.loadFromJSON(
  savedJson
);

canvas.requestRenderAll();

Current Fabric documentation defines loadFromJSON() as Promise-based.

Fabric JSON is useful.

But it is important to understand what it represents.

Fabric describes its JSON serialization primarily as a way to restore visual canvas state.

That is not necessarily identical to your annotation business model.


Prefer a Separate Annotation Schema

For example:

{
  "taskId": "task-10023",
  "imageId": "img-849",
  "version": 17,
  "annotations": [
    {
      "id": "ann-1",
      "type": "box",
      "label": "car",
      "left": 421.3,
      "top": 182.8,
      "width": 227.4,
      "height": 118.7,
      "scaleX": 1,
      "scaleY": 1,
      "angle": 0
    }
  ]
}

This data can be:

validated
versioned
queried
audited
converted
used by ML pipelines

without requiring Fabric.js.

Fabric then becomes:

annotation model
        ↓
rendering adapter
        ↓
Fabric objects

rather than your database schema being dictated by your canvas library.


Fabric JSON Can Still Be Useful

You may choose to save both:

canonical annotation data

and

optional Fabric visual snapshot

For example:

{
  "annotations": [...],
  "fabricSnapshot": {...}
}

The domain annotation data remains authoritative.

The Fabric snapshot can speed up restoration of complex visual state.

This gives you flexibility if the rendering library changes later.


Loading a Task Without Triggering Fake User Changes

When loading existing annotations:

Fetch task
    ↓
Populate domain store
    ↓
Create Fabric objects
    ↓
Render

you generally do not want to create:

history entries
autosave requests
dirty flags

for every object being restored.

Use an explicit hydration phase.

For example:

const hydratingRef =
  useRef(false);

Then:

async function hydrateTask(
  annotations
) {

  const canvas =
    fabricRef.current;

  if (!canvas) {
    return;
  }


  hydratingRef.current =
    true;


  try {

    canvas.clear();

    objectMapRef.current.clear();


    for (
      const annotation
      of annotations
    ) {

      const object =
        createFabricObject(
          annotation
        );


      canvas.add(object);


      objectMapRef.current.set(
        annotation.id,
        object
      );
    }


    canvas.requestRenderAll();

  } finally {

    hydratingRef.current =
      false;
  }
}

Event handlers can ignore initialization events while:

hydratingRef.current === true

Undo and Redo Need Semantic Boundaries

Undo should not record:

mouse moved 1 pixel
mouse moved 1 pixel
mouse moved 1 pixel
mouse moved 1 pixel

It should record:

Moved annotation ann-14
from position A
to position B

That means the right history boundary is usually:

object:modified

rather than:

object:moving

Two Common Undo/Redo Designs

Snapshot History

Store annotation document snapshots.

State 1
State 2
State 3
State 4

Undo:

State 4 → State 3

Advantages:

simple
reliable
easy to reason about

Disadvantages:

memory usage
large annotation documents
expensive restoration

Command History

Store operations.

ADD ann-14
MOVE ann-14
CHANGE_LABEL ann-14
DELETE ann-9

Each command has:

do
undo

Advantages:

efficient
clear audit trail
scales better

Disadvantages:

more engineering complexity

For a professional annotation platform, command-based history often becomes preferable once the application grows.


Don’t Serialize the Entire Canvas on Every Mouse Move

Avoid:

canvas.on(
  "mouse:move",
  () => {

    history.push(
      canvas.toJSON()
    );
  }
);

One two-second drag could create hundreds of snapshots.

Instead:

start transform
      ↓
Fabric manages intermediate state
      ↓
object:modified
      ↓
save one history entry

This is a much cleaner interaction model.


Autosave Should Operate on Committed State

Autosave should generally not run for every canvas event.

A good flow is:

User modifies object
        ↓
object:modified
        ↓
domain state updated
        ↓
revision increments
        ↓
debounce
        ↓
save annotation document

For example:

useEffect(() => {

  if (!state.dirty) {
    return;
  }


  const revision =
    state.revision;


  const timer =
    window.setTimeout(
      async () => {

        try {

          await saveAnnotations({
            taskId,
            annotations:
              Object.values(
                state.annotations
              ),
            revision
          });


          dispatch({
            type:
              "saveSucceeded",
            revision
          });

        } catch (error) {

          console.error(
            "Autosave failed",
            error
          );
        }

      },
      800
    );


  return () => {

    window.clearTimeout(
      timer
    );
  };

}, [
  state.revision,
  state.dirty,
  taskId
]);

Your reducer should only clear the dirty flag if the save corresponds to the current revision.

Otherwise:

revision 20 saving
↓
user creates revision 21
↓
revision 20 response arrives
↓
application incorrectly says "saved"

even though revision 21 is still unsaved.


Version Annotation Documents on the Backend

For multi-user annotation or QC workflows, autosave needs concurrency protection.

Suppose:

Annotator A loads version 14
QC user loads version 14

Annotator A saves → version 15
QC user later saves version 14

Without version checking, the QC user’s older document may overwrite newer work.

A safer backend contract is:

task ID
+
expected version
+
new annotation document

The backend can reject stale updates:

409 Conflict

rather than silently performing last-write-wins.


Persist Annotation Data Outside the Canvas

A serious annotation platform should eventually persist annotation records through an API and database.

If the backend uses PostgreSQL, database connections also need proper pooling—especially when autosave and multiple raters generate concurrent requests.

See:

PostgreSQL Connection Pooling in Node.js & Python

For deploying a containerized React backend or annotation API to a managed environment, see:

Deploying Containerized Python & Express Apps to Google Cloud Run


Canvas Resize Should Not Recreate Annotation State

Annotation tools often need responsive layouts.

Use a ResizeObserver to resize the Fabric canvas when its container changes.

For example:

useEffect(() => {

  const container =
    containerRef.current;

  const canvas =
    fabricRef.current;


  if (
    !container ||
    !canvas
  ) {
    return;
  }


  const observer =
    new ResizeObserver(
      entries => {

        const entry =
          entries[0];

        if (!entry) {
          return;
        }


        const {
          width,
          height
        } =
          entry.contentRect;


        canvas.setDimensions({
          width,
          height
        });


        canvas.requestRenderAll();
      }
    );


  observer.observe(
    container
  );


  return () => {

    observer.disconnect();
  };

}, []);

Fabric 7 removed the older setWidth() and setHeight() methods in favor of setDimensions().


Annotation Coordinates Should Be Independent of Display Resolution

Suppose the original image is:

4000 × 3000

but the browser displays it inside:

1000 × 750

If you store screen coordinates directly, the annotations may become wrong when:

window size changes
image opens on another monitor
canvas dimensions change
export happens at original resolution

Prefer coordinates in:

original image space

or a normalized coordinate system.

For example:

normalizedX =
annotationX / imageWidth

normalizedY =
annotationY / imageHeight

Then:

stored annotation

is independent of display size.

The Fabric scene is simply one projection of those coordinates.


Normalized Coordinates Can Simplify Model Integration

Example:

{
  "x": 0.31,
  "y": 0.22,
  "width": 0.18,
  "height": 0.14
}

For a:

4000 × 3000

image:

x = 1240
y = 660
width = 720
height = 420

For a:

1000 × 750

display:

x = 310
y = 165
width = 180
height = 105

The annotation remains logically identical.

This is particularly useful when annotations are later consumed by machine-learning pipelines.


Freehand and Polygon Tools Need Different Commit Rules

Bounding boxes have a natural:

mouse down → mouse up

boundary.

Freehand drawing is different.

Fabric emits:

path:created

after a brush creates a path.

That event is a much better place to commit freehand annotation state than every mouse:move.

Polygons may have another boundary:

click point
click point
click point
double-click / enter
↓
polygon completed

The broader principle remains:

Commit domain state when an annotation operation becomes meaningful, not whenever the pointer changes position.


Tool State Should Change Canvas Behavior

When the selected tool changes, configure Fabric accordingly.

For example:

Select tool
→ objects selectable
→ drawing disabled

Box tool
→ selection disabled
→ custom box drawing active

Pan tool
→ object selection disabled
→ viewport drag active

A central tool controller is much easier to reason about than scattering:

canvas.selection = ...
object.selectable = ...
canvas.isDrawingMode = ...

through unrelated React components.


Keep Fabric-Specific Logic in a Canvas Adapter

As annotation tools grow, avoid importing Fabric everywhere.

Instead:

React Components
       ↓
Annotation Commands
       ↓
Canvas Adapter
       ↓
Fabric.js

For example:

interface AnnotationCanvasAdapter {

  addAnnotation(
    annotation: Annotation
  ): void;

  updateAnnotation(
    annotation: Annotation
  ): void;

  deleteAnnotation(
    id: string
  ): void;

  selectAnnotations(
    ids: string[]
  ): void;

  fitImage(): void;

  setTool(
    tool: Tool
  ): void;
}

Now Fabric is an implementation detail.

If you later replace Fabric.js or move some rendering into WebGL, the rest of the application is less tightly coupled.


Avoid Global State for Everything

Tools such as:

Redux
Zustand
Jotai
Context

can be useful.

But they do not solve bad state ownership.

Do not put this into a global React store:

current mouse coordinates
current drag delta
Fabric canvas instance
every Fabric object
current render frame

Global state is more appropriate for:

task information
annotations
selected IDs
workflow state
user settings
labels
save state
undo history

High-frequency imperative state should remain close to the canvas.


A Practical State Architecture

For a larger annotation system, I prefer something like:

Backend
│
│ canonical persisted document
│
▼
Annotation Store
│
├── annotations
├── labels
├── version
├── dirty state
├── history
└── workflow status
│
├───────────────┐
│               │
▼               ▼
React UI        Canvas Adapter
│               │
│               ▼
Toolbar         Fabric.js
Sidebar         │
QC Panel        ▼
Task Nav        HTML Canvas

Fabric is the interactive rendering engine.

It is not the business workflow engine.


Testing Canvas-Based Annotation Tools

Canvas applications can be difficult to test if everything is verified through screenshots.

Instead, separate testing layers.

Reducer tests

Test:

add annotation
update annotation
delete annotation
undo
redo
save revision logic

as normal pure functions.

Canvas adapter tests

Test:

domain annotation
→ expected Fabric object

Fabric object
→ expected domain annotation

Browser tests

Use a real browser to verify:

draw box
select object
drag object
change label
delete
undo
redo
save
reload

For browser automation strategy, see:

Playwright vs Selenium for Headless Browser Automation

Avoid relying exclusively on pixel-perfect screenshots.

Canvas rendering can vary slightly because of:

browser versions
font rendering
GPU behavior
antialiasing
device pixel ratio

Prefer semantic assertions where possible.


Test the Saved Annotation Data

After drawing a box, an end-to-end test can verify:

UI interaction succeeded
+
annotation JSON is correct
+
backend persisted it

rather than merely:

screenshot looks approximately right

For example:

{
  "id": "ann-200",
  "label": "pedestrian",
  "left": 631.5,
  "top": 202.1,
  "width": 94.8,
  "height": 231.3
}

is a stronger assertion than testing a few pixels.


Annotation Export Should Be a Data Pipeline

Many annotation projects eventually export:

JSON
CSV
COCO
YOLO
Pascal VOC
custom ML schemas

Do not make the browser canvas responsible for every output format.

Prefer:

Canonical Annotation Schema
        ↓
Export Layer
        ├── JSON
        ├── CSV
        ├── COCO
        └── YOLO

For larger JSON and CSV transformation workflows, see:

Automating File Processing with Python: Parsing Complex JSON and Nested CSVs at Scale


Canvas Tools Still Need Accessible UI

The visual annotation itself may be canvas-based, but the surrounding application should not require the canvas for every operation.

Expose important information through normal DOM controls:

annotation list
selected annotation
label
coordinates
delete button
QC status
keyboard shortcut help

That improves:

accessibility
keyboard workflows
testing
debugging
screen-reader compatibility

A canvas should be the interaction surface, not the only representation of application state.


Common Performance Problems

Updating React on every mouse:move

Result:

excessive re-renders
poor pointer responsiveness

Keep temporary movement in Fabric/ref state.

Rebuilding all Fabric objects after one annotation changes

Result:

unnecessary object creation
selection loss
render overhead

Update only the changed object.

Saving after every pointer event

Result:

hundreds of API requests
race conditions
backend pressure

Commit and debounce.

Keeping huge history snapshots

Result:

memory growth
slow undo

Use command history or bounded snapshots.

Using Fabric objects as React state

Result:

mutable state
difficult debugging
unpredictable renders

Store serializable domain records instead.

Registering listeners repeatedly

Result:

duplicate actions
multiple dispatches
memory leaks

Subscribe once and clean up correctly.


Common State Management Mistakes

Mistake 1: React controls every pixel

Fabric move
→ React state
→ Fabric update
→ React state

This creates unnecessary synchronization.

Mistake 2: Fabric controls everything

Then React does not reliably know:

what is selected
whether the document is dirty
what should be saved
what QC status is active

Mistake 3: Two independent sources of truth

React says:

ann-10 is at x=100

Fabric says:

ann-10 is at x=112

Sooner or later, one overwrites the other.

Mistake 4: Events become the application architecture

object:added
→ label assignment
→ history
→ API
→ analytics
→ workflow
→ navigation

Fabric explicitly advises against using its events as a general application message system.

Mistake 5: Saving display coordinates

Annotations break when viewport scale changes.

Store scene/original-image coordinates.


A Better Event Strategy

A production annotation tool can classify events into three groups.

Event TypeExamplesHandling
Transientmoving, scaling, pointer moveFabric/ref
Commitmodified, path created, draw completedDomain state/history
UIselection created/clearedReact state
Application commanddelete, change label, duplicateExplicit function
Persistenceautosave, submit, QCBackend workflow

This separation prevents a large amount of accidental complexity.


A mature React annotation tool might look like:

AnnotationWorkspace
│
├── AnnotationToolbar
│
├── AnnotationCanvas
│   ├── useFabricCanvas
│   ├── useCanvasEvents
│   └── canvasAdapter
│
├── AnnotationSidebar
│
├── AnnotationProperties
│
├── AnnotationHistory
│
└── AnnotationStatusBar

State layer:

annotationReducer
annotationStore
historyManager
persistenceService

Canvas layer:

fabricFactory
canvasAdapter
coordinateMapper
toolController

This structure scales much better than putting 2,000 lines into:

AnnotationCanvas.tsx

Production State Flow

A normal interaction should look like this:

User selects "Bounding Box"
        ↓
React tool state changes
        ↓
Canvas adapter changes interaction mode
        ↓
User drags rectangle
        ↓
Fabric manages live geometry
        ↓
Mouse up
        ↓
Annotation created
        ↓
Reducer receives annotationAdded
        ↓
History receives ADD command
        ↓
Document becomes dirty
        ↓
Autosave debounce
        ↓
Backend saves version
        ↓
UI shows "Saved"

Moving the box:

User drags annotation
        ↓
Fabric handles movement
        ↓
object:modified
        ↓
Convert Fabric → domain annotation
        ↓
Reducer update
        ↓
History entry
        ↓
Autosave

Selection:

User clicks annotation
        ↓
Fabric selection event
        ↓
Extract annotation ID
        ↓
React selectedIds
        ↓
Properties panel updates

Each layer has one clear responsibility.


The architecture I recommend is:

React/domain store

Canonical for:

annotations that will be saved
labels
task state
workflow state
selection IDs
history
dirty state

Fabric.js

Canonical during:

active pointer gesture
temporary transform
live rendering
selection visualization
viewport movement

Refs

Canonical for:

Fabric Canvas instance
lookup maps
active drawing operation
latest tool value
high-frequency temporary values
hydration flags

Backend

Canonical for:

persisted task version
audit history
cross-session state
collaborative conflict detection

The boundaries are more valuable than whichever state library you choose.


Final Thoughts

React and Fabric.js work very well together.

Problems usually begin when both are asked to manage the same state at the same time.

React is excellent at:

application state
UI state
workflow state
business rules

Fabric.js is excellent at:

interactive geometry
hit testing
canvas rendering
selection
dragging
scaling
rotation
drawing
viewport transforms

Use each where it is strongest.

The architecture can be summarized as:

React owns meaning.

Fabric owns interaction.

Refs connect the two.

Events report user interactions.

Commands handle application actions.

The domain model owns persisted annotations.

And for high-frequency canvas interaction:

Don't synchronize every frame.

Synchronize meaningful changes.

That single principle makes annotation tools considerably easier to scale from a simple bounding-box prototype into a production platform with thousands of images, multiple annotation types, automated assistance, QC workflows, autosave, undo/redo, and collaborative task management.


Official Documentation and Technical References

The React and Fabric.js behavior described in this article was checked against the current official documentation.

React

React — Managing State

React — Referencing Values with Refs

React — Synchronizing with Effects

React — Extracting State Logic into a Reducer

React — You Might Not Need an Effect

Fabric.js

Fabric.js — Official Documentation

Fabric.js — Core Concepts

Fabric.js — Working with Events

Fabric.js — Canvas API

Fabric.js — Canvas Events API

Fabric.js — Objects and Custom Properties

Fabric.js — Upgrading to Fabric.js 7

Scroll to Top