Skip to content

Fix: tldraw Not Working — Store Persistence, Custom Shapes, Sync, and Asset Uploads

FixDevs ·

Quick Answer

How to fix tldraw errors — Tldraw component blank screen, persistenceKey vs server sync, custom shape util, asset upload handler, undo/redo, dark mode, snapshot vs store load, and tldraw sync server.

The Error

You drop in <Tldraw /> and the canvas is blank:

import { Tldraw } from "@tldraw/tldraw";
import "@tldraw/tldraw/tldraw.css";

export default function App() {
  return <div style={{ position: "fixed", inset: 0 }}><Tldraw /></div>;
}
// Renders a 0x0 canvas.

Or your custom shape doesn’t render:

class MyShapeUtil extends ShapeUtil<MyShape> {
  static type = "my-shape" as const;
  
  component(shape: MyShape) {
    return <div>Hello</div>;
  }
}

<Tldraw shapeUtils={[MyShapeUtil]} />
// Creating the shape adds nothing to the canvas.

Or persistence loads stale data:

<Tldraw persistenceKey="my-board" />
// User reloads → sees data from 3 hours ago, not current state.

Or pasted images don’t upload to your server:

[paste image] → image appears in canvas → on save, it's a data URL, not a server URL.

Why This Happens

tldraw is a React canvas library backed by a reactive store (the Editor instance). Most issues map to:

  • CSS and sizing. The Tldraw component fills its parent. Without a sized parent (position: fixed; inset: 0, or explicit width/height), it renders 0x0.
  • Shape utilities. Custom shapes need a ShapeUtil subclass implementing component, indicator, getDefaultProps, etc. Without all required methods, shapes don’t render.
  • persistenceKey is local. It stores in IndexedDB by that key. Same key across users on the same machine = shared data. For multi-user, use sync server instead.
  • Asset handling is pluggable. Pasted/dropped images become “assets” in the store. The default uploads to a data URL; for server storage, provide onCreateAssetFromFile.

Fix 1: Set Up the Component With Proper Sizing

import { Tldraw } from "@tldraw/tldraw";
import "@tldraw/tldraw/tldraw.css";

export default function App() {
  return (
    <div style={{ position: "fixed", inset: 0 }}>
      <Tldraw />
    </div>
  );
}

The parent must have a defined size. Common patterns:

  • Full screen: position: fixed; inset: 0.
  • Inline: width: 800px; height: 600px;.
  • Flex container: parent has display: flex; flex: 1;.

Don’t forget the CSS import — tldraw’s styles include all the UI elements. Without them, you see blank canvas with no toolbar.

For SSR (Next.js App Router):

"use client";

import { Tldraw } from "@tldraw/tldraw";
import "@tldraw/tldraw/tldraw.css";

export default function BoardClient() {
  return (
    <div style={{ position: "fixed", inset: 0 }}>
      <Tldraw />
    </div>
  );
}

tldraw is a Client Component — it uses browser-only APIs (canvas, IndexedDB, pointer events). Wrap in a Client boundary.

Pro Tip: For embedded usage (smaller than full screen), wrap in a sized div and pass forceMobile={false}. The default is responsive but may show the mobile UI in narrow containers.

Fix 2: Persistence via persistenceKey

For client-side persistence (single user, same browser):

<Tldraw persistenceKey="my-board" />

This saves the entire store to IndexedDB under the key tldraw-my-board. Reloads restore the previous state.

For multiple boards in the same app:

function BoardPage({ boardId }: { boardId: string }) {
  return <Tldraw persistenceKey={`board-${boardId}`} />;
}

Each boardId has its own IndexedDB entry.

To clear:

import { clear } from "@tldraw/tldraw/store";
await clear();  // Clears all tldraw IndexedDB data.

Or via DevTools:

Application tab → IndexedDB → tldraw-* databases → delete

Common Mistake: Using the same persistenceKey for “save my work” and “load a specific shared document.” The persistence layer is per-browser. For shared boards, use sync (Fix 5).

Fix 3: Custom Shapes

Define a ShapeUtil subclass:

import {
  ShapeUtil,
  TLBaseShape,
  HTMLContainer,
  Rectangle2d,
  Geometry2d,
} from "@tldraw/tldraw";

type MyShape = TLBaseShape<
  "my-shape",
  {
    w: number;
    h: number;
    text: string;
  }
>;

class MyShapeUtil extends ShapeUtil<MyShape> {
  static type = "my-shape" as const;

  getDefaultProps(): MyShape["props"] {
    return { w: 200, h: 100, text: "Hello" };
  }

  getGeometry(shape: MyShape): Geometry2d {
    return new Rectangle2d({
      width: shape.props.w,
      height: shape.props.h,
      isFilled: true,
    });
  }

  component(shape: MyShape) {
    return (
      <HTMLContainer
        style={{
          width: shape.props.w,
          height: shape.props.h,
          background: "lightblue",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        {shape.props.text}
      </HTMLContainer>
    );
  }

  indicator(shape: MyShape) {
    return <rect width={shape.props.w} height={shape.props.h} />;
  }
}

<Tldraw shapeUtils={[MyShapeUtil]} />

Five required methods:

  • type — string identifier.
  • getDefaultProps — initial values when a new shape is created.
  • getGeometry — hit-testing and selection bounds.
  • component — what renders inside the shape.
  • indicator — the selection outline (SVG element).

To create a shape programmatically:

editor.createShape({
  type: "my-shape",
  x: 100,
  y: 100,
  props: { w: 200, h: 100, text: "Hi" },
});

Common Mistake: Missing indicator. Selection looks broken — no outline appears. Always implement it.

For interactive shapes (drag handles, editable text), override canEdit, canResize, and use useEditor() inside the component for state changes.

Fix 4: Asset Upload Handler

By default, pasted images become base64 data URLs in the store — bloats your snapshot. To upload to a server:

import { Tldraw, type TLAssetStore } from "@tldraw/tldraw";

const assetStore: TLAssetStore = {
  async upload(asset, file) {
    // file is a Blob/File. Upload to your server:
    const form = new FormData();
    form.append("file", file);
    
    const response = await fetch("/api/upload", {
      method: "POST",
      body: form,
    });
    const { url } = await response.json();
    
    return url;  // Return the server URL.
  },

  resolve(asset) {
    if (asset.type === "image") {
      return asset.props.src;
    }
    return null;
  },
};

<Tldraw assets={assetStore} />

Now pasted/dropped images upload to your server. The asset record stores only the URL — snapshots stay small.

For S3/Cloudflare R2 directly from the browser (presigned URLs):

async upload(asset, file) {
  // 1. Get a presigned URL from your backend:
  const { uploadUrl, finalUrl } = await fetch("/api/presign", {
    method: "POST",
    body: JSON.stringify({ filename: asset.props.name, type: file.type }),
  }).then((r) => r.json());

  // 2. Upload directly to S3/R2:
  await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers: { "content-type": file.type },
  });

  // 3. Return the final URL:
  return finalUrl;
}

This bypasses your backend for the actual file bytes — fast and scalable.

Common Mistake: Returning a URL that’s not CORS-accessible by the canvas. The browser canvas tainting rules can break export if your asset URLs don’t allow crossOrigin.

Fix 5: Multi-User Sync via tldraw Sync

For real-time collaboration, use @tldraw/sync (self-hosted server) or tldraw’s hosted sync:

npm install @tldraw/sync

Server (Node/Cloudflare Worker):

import { TLSocketRoom } from "@tldraw/sync-core";

const room = new TLSocketRoom({
  initialSnapshot: undefined,  // Or load from DB
  onSessionRemoved: (room) => {
    if (room.getNumActiveSessions() === 0) {
      // Persist the snapshot
    }
  },
  onDataChange: () => {
    // Throttled snapshot persistence
  },
});

// In your WebSocket handler:
room.handleSocketConnect({ sessionId, socket });

Client:

import { useSync } from "@tldraw/sync";
import { Tldraw } from "@tldraw/tldraw";

function CollabBoard({ roomId }: { roomId: string }) {
  const store = useSync({
    uri: `wss://my-server.com/connect/${roomId}`,
    assets: assetStore,
  });

  return <Tldraw store={store} />;
}

useSync opens a WebSocket and returns a synced store. Changes flow bi-directionally.

For Cloudflare Workers Durable Objects, tldraw provides a template — each room is a DO that holds connected sockets and the canonical state. This pairs beautifully with edge-distributed apps.

Pro Tip: Self-host the sync server unless you specifically want managed. The protocol is well-documented and the code is small (~few hundred lines).

Fix 6: Working With the Editor

For programmatic control, use the editor instance via onMount:

import { Tldraw, Editor } from "@tldraw/tldraw";

function MyBoard() {
  const [editor, setEditor] = useState<Editor | null>(null);

  return (
    <Tldraw
      onMount={(editor) => {
        setEditor(editor);
      }}
    />
  );
}

Or with useEditor() inside a child component:

import { useEditor } from "@tldraw/tldraw";

function Toolbar() {
  const editor = useEditor();

  return (
    <button onClick={() => editor.selectAll()}>Select All</button>
  );
}

<Tldraw>
  <Toolbar />
</Tldraw>

Common editor methods:

editor.selectAll();
editor.deleteShapes(editor.getSelectedShapeIds());
editor.createShape({ type: "geo", x: 100, y: 100, props: { geo: "rectangle" } });
editor.zoomToFit();
editor.zoomToContent();
editor.setCurrentTool("draw");

const allShapes = editor.getCurrentPageShapes();
const selected = editor.getSelectedShapeIds();

// Listen to changes:
editor.store.listen(() => {
  console.log("store updated");
});

For undo/redo:

editor.undo();
editor.redo();

// Mark a transaction:
editor.batch(() => {
  editor.createShape(...);
  editor.createShape(...);
});  // All shapes added in one undo step.

Fix 7: Snapshots — Load and Save

Snapshots are the serialized form of the store:

import { getSnapshot, loadSnapshot } from "@tldraw/tldraw";

// Save:
const snapshot = getSnapshot(editor.store);
const json = JSON.stringify(snapshot);
// Persist to backend.

// Load:
const parsed = JSON.parse(json);
loadSnapshot(editor.store, parsed);

getSnapshot returns a structured object — schema version + records. Treat as opaque JSON.

For initial load on mount:

<Tldraw
  snapshot={initialSnapshot}  // Provided once at mount
/>

Or load after mount:

<Tldraw
  onMount={(editor) => {
    if (initialSnapshot) {
      editor.store.loadSnapshot(initialSnapshot);
    }
  }}
/>

For incremental updates from a remote source, apply changes through the sync protocol — not by loading new snapshots.

Common Mistake: Saving snapshots on every change. Throttle to 1-5 seconds; snapshots can be large (especially with embedded assets).

Fix 8: Dark Mode

tldraw has built-in dark mode:

editor.user.updateUserPreferences({ colorScheme: "dark" });
// Or "light", "system"

Or via component:

<Tldraw inferDarkMode />

inferDarkMode makes tldraw match the system’s color scheme via media query.

For custom theming, override CSS variables in your stylesheet:

.tl-container {
  --color-background: #1a1a1a;
  --color-text: #ffffff;
  --color-grid: #333;
}

Inspect the tldraw CSS bundle for the full list of variables.

Pro Tip: tldraw’s UI is React under the hood. You can swap UI components via components prop:

<Tldraw
  components={{
    Toolbar: MyCustomToolbar,
    StylePanel: MyStylePanel,
  }}
/>

Customize the full UI without forking the library.

Still Not Working?

A few less-obvious failures:

  • Canvas not interactive. Pointer events blocked by a CSS overlay. Inspect with DevTools — ensure no parent has pointer-events: none.
  • Persistence loses data after schema migration. tldraw versions sometimes change shape schemas. Old data may not load in new versions. Implement migrations via migrations config.
  • Slow with many shapes. Hundreds is fine; thousands may need a stress test. Consider virtualization or pagination for huge canvases.
  • Asset URLs 404 after deploy. Snapshot stores absolute URLs. Move buckets and old snapshots break. Use relative paths or a CDN with stable URLs.
  • Server sync drops connections. WebSocket timeouts. Configure your reverse proxy (nginx, Cloudflare) to allow long-lived WS — 60s+.
  • Cursor positions don’t sync. Awareness/presence is separate from store sync. tldraw sync handles both; verify both channels are connected.
  • Export to PNG/SVG renders blank. Images with crossOrigin: anonymous headers required. Configure your asset server’s CORS.
  • useSync returns undefined. Connection still establishing. Show a loading state until the store is ready.

For related React, canvas, and real-time collaboration issues, see React hydration error, Liveblocks not working, Supabase Realtime not working, and Socket.IO not connecting.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles