Claude Code with tldraw: Custom Shapes Without the State Bugs
Why tldraw without CLAUDE.md ships canvases that lose state on reload
tldraw looks like a drop-in component until the first time a user closes the tab. Then the document they spent twenty minutes building is gone, the persistence layer that was meant to save it never received the snapshot, and the bug report says "the whiteboard threw away my work." The library is not at fault. The tldraw store is a fast, reactive, in-memory document database. It will not write to your backend unless you explicitly tell it to, and it will not load from your backend unless you explicitly hand it a snapshot at mount time.
Claude Code does not know any of this by default. It generates <Tldraw />, ships a useEffect that calls editor.getSnapshot() every render, and stops. The result demos beautifully on localhost and corrupts the document the moment two writes race each other. The most common Claude defaults that break tldraw in production: custom shapes declared as plain objects instead of ShapeUtil subclasses so the canvas cannot render them, tools added without a StateNode so the keyboard shortcuts conflict with the default tool, persistence wired to React state which fights the editor's own reactivity, shape IDs generated with Math.random() so collaborative sessions collide, and editor references captured in closures so the cleanup runs against a stale instance.
This guide covers the CLAUDE.md configuration that locks Claude Code into tldraw's correct usage patterns: custom shapes authored as ShapeUtil classes with explicit type registration, tools registered as StateNode children of the root state, persistence handled via loadSnapshot and store.listen rather than React effects, shape IDs minted via createShapeId so they participate in the store's UUID scheme, and editor references kept inside useEditor rather than mirrored into component state. For the React component patterns the canvas depends on, Claude Code with React covers the rendering model that pairs cleanly with tldraw's editor lifecycle.
The tldraw CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a tldraw integration it needs to declare: the tldraw version line, the editor mount pattern, the custom shape authoring rules, the tool registration rules, the persistence boundary, and the hard rules that block the mistakes Claude makes most often.
# tldraw rules
## Stack
- tldraw ^3.x (React 18 compatible)
- @tldraw/state for the reactive store primitives
- @tldraw/utils for ID generation and snapshot helpers
- React 18.3+ with Suspense boundaries around the Tldraw component
- Editor mounted once per route, NEVER re-created on parent re-render
## Project structure
- src/canvas/ , canvas configuration and shape registry
- src/canvas/shapes/ , one folder per custom ShapeUtil
- src/canvas/tools/ , one folder per custom StateNode tool
- src/canvas/persistence/ , snapshot load/save boundary
- src/canvas/handlers/ , editor event listeners (selection, change)
## Editor lifecycle (MANDATORY)
- ALWAYS use the `onMount` callback to capture the editor instance
- NEVER store the editor in React state (causes re-mount loops)
- NEVER read editor.store synchronously inside a React render path
- ALWAYS clean up store listeners in the unmount path
- Editor reference is captured ONCE and held in a ref or zustand store
## Custom shapes (MANDATORY)
- Custom shapes MUST extend ShapeUtil<T>
- Every shape declares: type, getDefaultProps, component, indicator
- Shape props MUST be serialisable (no functions, no React elements)
- Shape IDs MUST be minted via createShapeId(), NEVER via Math.random
- Component returns SVG-compatible JSX rendered inside the canvas
- Indicator returns the selection outline geometry
## Tools (MANDATORY)
- Tools extend StateNode and are registered as children of the root state
- One tool per file, named with the convention <Name>Tool
- Tool registers idle, pointing, dragging child states
- Pointer events handled via onPointerDown / onPointerMove / onPointerUp
- Tool emits createShape transactions, never mutates store directly
## Persistence (MANDATORY)
- Load: hydrate via loadSnapshot inside onMount, NEVER inside useEffect
- Save: register store.listen for 'document' scope changes
- Debounce saves to backend (default 800ms) to avoid flooding
- Snapshot writes are full document, not partial diffs
- Backend acknowledges before clearing the local pending flag
## Hard rules
- NEVER call editor.getSnapshot() inside a React render path
- NEVER mutate shape props by assignment, use editor.updateShapes
- NEVER store the editor instance in useState
- NEVER use Math.random() for shape IDs
- NEVER register two tools with the same id
- NEVER ship without an onMount-based snapshot load
- ALWAYS handle editor === null during initial render
- ALWAYS dispose store listeners on unmount
Five rules here prevent the majority of incidents Claude generates without them.
The editor lifecycle rule prevents the re-mount loop. tldraw's Tldraw component creates an editor internally. If you mirror that editor into React state and then read the state in a parent component, the parent re-renders, which re-renders Tldraw, which may create a new editor, which fires onMount again, which writes to state, which re-renders. The rule "never store the editor in React state" forces Claude to capture it in a ref or a zustand store, both of which avoid the trigger.
The custom shape rule prevents the "shape does not render" trap. tldraw's renderer only knows about shapes registered via a ShapeUtil subclass passed to the Tldraw component's shapeUtils prop. A shape literal added to the store via editor.createShapes() without a registered ShapeUtil of that type silently fails to render. The rule "custom shapes MUST extend ShapeUtil" forces the registration step into the project structure, with one folder per shape, so the registry array can be assembled from the folders.
The tool registration rule prevents the keyboard shortcut conflict. tldraw's root state has children for the built-in tools (select, draw, erase, etc.). A custom tool added at the same level competes for the same shortcut namespace. The rule "tools extend StateNode and are registered as children of the root state" makes the parent-child relationship explicit, so the tool's id is what the user invokes via shortcut and the engineer can avoid collisions deliberately.
The persistence rule prevents the data loss bug. Engineers reach for useEffect(() => editor.loadSnapshot(snap), [snap]) because that is the React idiom. The effect runs after mount, by which point onMount may have already fired with an empty store, which store.listen interprets as "the user cleared the document" and writes an empty snapshot back to the backend. The rule "load via onMount, NEVER via useEffect" puts the load inside the same callback the editor uses to declare itself ready, so the empty-document race cannot happen.
The shape ID rule prevents the collaborative collision. tldraw uses prefix-typed UUIDs like shape:abc123. createShapeId() mints one that conforms. Math.random().toString() produces a string that the store will accept but that another client may produce the same value for, which causes shape merges in the wrong order under collaboration. The rule "shape IDs MUST be minted via createShapeId()" prevents the entire category.
Install and editor setup
Install tldraw and the related store packages:
pnpm add tldraw
For the styles, tldraw ships a CSS file that must be imported once at the app root:
// src/app/layout.tsx (Next.js App Router)
import 'tldraw/tldraw.css';
Create the canvas with the stable onMount pattern:
// src/canvas/Canvas.tsx
'use client';
import { useCallback, useRef } from 'react';
import { Tldraw, type Editor, type TLEditorSnapshot } from 'tldraw';
import { customShapeUtils } from './shapes';
import { customTools } from './tools';
import { hydrateAndPersist } from './persistence/hydrate';
type Props = {
documentId: string;
initialSnapshot: TLEditorSnapshot | null;
onPersist: (snapshot: TLEditorSnapshot) => Promise<void>;
};
export function Canvas({ documentId, initialSnapshot, onPersist }: Props) {
const editorRef = useRef<Editor | null>(null);
const handleMount = useCallback(
(editor: Editor) => {
editorRef.current = editor;
hydrateAndPersist(editor, initialSnapshot, onPersist);
},
[initialSnapshot, onPersist],
);
return (
<Tldraw
shapeUtils={customShapeUtils}
tools={customTools}
onMount={handleMount}
persistenceKey={documentId}
/>
);
}
Three things in this snippet that the CLAUDE.md rule enforces. The editor is held in a ref, not in state, so the parent does not re-render when the editor mounts. The onMount callback is the single place where the editor instance is captured, hydrated, and wired to persistence. The persistenceKey prop tells tldraw which local-storage slot to use, which is the right level of identity for the in-memory store.
Authoring a custom shape with ShapeUtil
The ShapeUtil is the unit of canvas extensibility. A correctly-authored shape has a type registration, a getDefaultProps for new instances, a component for the visual, and an indicator for the selection outline.
A complete sticky note shape:
// src/canvas/shapes/sticky/StickyShape.ts
import {
HTMLContainer,
Rectangle2d,
ShapeUtil,
T,
type TLBaseShape,
type TLOnResizeHandler,
} from 'tldraw';
type StickyShapeProps = {
w: number;
h: number;
text: string;
color: 'yellow' | 'pink' | 'blue';
};
export type StickyShape = TLBaseShape<'sticky', StickyShapeProps>;
export class StickyShapeUtil extends ShapeUtil<StickyShape> {
static override type = 'sticky' as const;
static override props = {
w: T.number,
h: T.number,
text: T.string,
color: T.literalEnum('yellow', 'pink', 'blue'),
};
getDefaultProps(): StickyShapeProps {
return { w: 200, h: 200, text: '', color: 'yellow' };
}
getGeometry(shape: StickyShape) {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
});
}
component(shape: StickyShape) {
return (
<HTMLContainer
style={{
width: shape.props.w,
height: shape.props.h,
background: backgroundFor(shape.props.color),
padding: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
fontFamily: 'system-ui',
fontSize: 14,
overflow: 'hidden',
}}
>
{shape.props.text || 'Sticky note'}
</HTMLContainer>
);
}
indicator(shape: StickyShape) {
return <rect width={shape.props.w} height={shape.props.h} />;
}
override onResize: TLOnResizeHandler<StickyShape> = (shape, info) => {
return {
props: {
w: Math.max(80, shape.props.w * info.scaleX),
h: Math.max(80, shape.props.h * info.scaleY),
},
};
};
}
function backgroundFor(color: StickyShapeProps['color']): string {
switch (color) {
case 'pink':
return '#fce7f3';
case 'blue':
return '#dbeafe';
case 'yellow':
default:
return '#fef3c7';
}
}
The shape fields work together. type is the discriminator the store uses to look up the ShapeUtil. props is the validator for the serialised shape: T.number, T.string, and T.literalEnum come from tldraw's runtime type validator and make sure a shape loaded from a snapshot has the expected shape. getDefaultProps defines what a freshly-created sticky looks like. getGeometry returns a Rectangle2d that tldraw uses for hit-testing, intersection with the selection tool, and snapping. component returns the visible content rendered inside an HTMLContainer, which is tldraw's wrapper for non-SVG visuals embedded in the canvas. indicator returns the SVG path drawn when the shape is selected.
The registry array passed to <Tldraw shapeUtils={...} />:
// src/canvas/shapes/index.ts
import { StickyShapeUtil } from './sticky/StickyShape';
import { CalloutShapeUtil } from './callout/CalloutShape';
import { ArrowAnnotationShapeUtil } from './arrow-annotation/ArrowAnnotationShape';
export const customShapeUtils = [
StickyShapeUtil,
CalloutShapeUtil,
ArrowAnnotationShapeUtil,
];
Add a shape scaffolding rule to CLAUDE.md:
## Shape scaffolding template (CONCRETE)
A new custom shape goes in src/canvas/shapes/<name>/<Name>Shape.ts with this shape:
class <Name>ShapeUtil extends ShapeUtil<<Name>Shape> {
static override type = '<name>' as const;
static override props = { ... T.validators ... };
getDefaultProps(): <Name>ShapeProps { ... }
getGeometry(shape): Geometry2d { ... }
component(shape): JSX.Element { ... }
indicator(shape): JSX.Element { ... }
onResize?(shape, info): Partial<TLShape> { ... }
}
ALWAYS: register T.validators for every prop
NEVER: ship a shape without getGeometry (hit-testing breaks)
Authoring a custom tool with StateNode
Tools are state machines. The root state has children (select, draw, erase), each of which has child states for the steps inside the tool's interaction. A custom tool adds a child to the root and declares its own child states for idle, pointing, dragging, and any tool-specific phases.
A complete sticky-note tool:
// src/canvas/tools/sticky/StickyTool.ts
import {
createShapeId,
StateNode,
type TLPointerEventInfo,
} from 'tldraw';
class IdleState extends StateNode {
static override id = 'idle' as const;
override onPointerDown = (info: TLPointerEventInfo) => {
this.parent.transition('pointing', info);
};
}
class PointingState extends StateNode {
static override id = 'pointing' as const;
override onEnter = (info: TLPointerEventInfo) => {
const { editor } = this;
const id = createShapeId();
editor.createShape({
id,
type: 'sticky',
x: info.point.x,
y: info.point.y,
props: { w: 200, h: 200, text: '', color: 'yellow' },
});
editor.setSelectedShapes([id]);
editor.setEditingShape(id);
this.parent.transition('idle');
};
}
export class StickyTool extends StateNode {
static override id = 'sticky' as const;
static override initial = 'idle' as const;
static override children = () => [IdleState, PointingState];
}
Register the tool:
// src/canvas/tools/index.ts
import { StickyTool } from './sticky/StickyTool';
export const customTools = [StickyTool];
The state machine fields work together. id is what the toolbar and keyboard shortcuts use to refer to the tool. initial is the child state the tool enters when activated. children is the lazy factory that returns the child StateNode classes. The IdleState waits for a click. The PointingState runs on entry, creates the shape with createShapeId(), selects it, and transitions back to idle so the user can immediately type in the sticky.
The editor.createShape() call is a transaction. Under the hood it dispatches a store change that other listeners (persistence, collaboration, undo history) can react to. The rule "tool emits createShape transactions, never mutates store directly" makes the transactional boundary explicit.
Add a tool scaffolding rule to CLAUDE.md:
## Tool scaffolding template (CONCRETE)
A new custom tool goes in src/canvas/tools/<name>/<Name>Tool.ts with this shape:
class IdleState extends StateNode { static id = 'idle'; ... }
class PointingState extends StateNode { static id = 'pointing'; ... }
class <Name>Tool extends StateNode {
static id = '<name>';
static initial = 'idle';
static children = () => [IdleState, PointingState, ...];
}
ALWAYS: mint shape IDs via createShapeId()
NEVER: call editor.store.update() directly from a tool, use editor commands
Persistence that survives reloads
The persistence boundary has three jobs: load the document on mount, listen for changes during the session, and write changes back to the backend. tldraw exposes editor.store.loadSnapshot() for the first and editor.store.listen() for the second. The third is your code.
A complete persistence boundary:
// src/canvas/persistence/hydrate.ts
import {
type Editor,
type TLEditorSnapshot,
loadSnapshot,
getSnapshot,
} from 'tldraw';
const SAVE_DEBOUNCE_MS = 800;
export function hydrateAndPersist(
editor: Editor,
initialSnapshot: TLEditorSnapshot | null,
onPersist: (snapshot: TLEditorSnapshot) => Promise<void>,
): () => void {
if (initialSnapshot) {
loadSnapshot(editor.store, initialSnapshot);
}
let pending = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = async () => {
timer = null;
if (!pending) return;
pending = false;
const snapshot = getSnapshot(editor.store);
try {
await onPersist(snapshot);
} catch (err) {
pending = true;
console.error('persist failed, will retry on next change', err);
}
};
const unsubscribe = editor.store.listen(
() => {
pending = true;
if (timer === null) {
timer = setTimeout(flush, SAVE_DEBOUNCE_MS);
}
},
{ scope: 'document', source: 'user' },
);
return () => {
unsubscribe();
if (timer !== null) {
clearTimeout(timer);
void flush();
}
};
}
Three patterns in this boundary that the CLAUDE.md rule enforces. The snapshot is loaded inside the same function that registers the listener, so there is no window where the editor is mounted, empty, and being persisted as empty. The listener filter { scope: 'document', source: 'user' } ignores reactive store changes that come from the editor's own UI state (cursor position, hover, selection rectangle), which keeps the backend write rate sane. The debounce delays the write so a burst of keystrokes during text editing produces one save, not fifty.
For the backend that receives the snapshot and stores it, Claude Code with Convex covers the reactive database patterns that pair cleanly with tldraw's snapshot model. For the Next.js routing layer that mounts the canvas at a per-document URL, Claude Code with Next.js covers the App Router patterns that suit a per-document canvas page.
Common Claude Code mistakes with tldraw
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Editor stored in useState
Claude generates:
const [editor, setEditor] = useState<Editor | null>(null);
return <Tldraw onMount={setEditor} />;
The setEditor re-renders the parent, which re-renders Tldraw, which can call onMount again. The chain looks fine in the IDE and produces subtle remount bugs under React Strict Mode.
Correct pattern: hold the editor in a useRef or a zustand store, both of which avoid triggering React re-renders.
2. Custom shape without ShapeUtil registration
Claude generates editor.createShapes([{ type: 'sticky', ... }]) without first passing StickyShapeUtil to <Tldraw shapeUtils={...} />. The shape sits in the store, but tldraw's renderer does not know how to draw it, so nothing appears on the canvas.
Correct pattern: every custom type has a ShapeUtil subclass in the registry array.
3. Snapshot loaded inside useEffect
Claude generates:
useEffect(() => {
editor?.store.loadSnapshot(initialSnapshot);
}, [editor, initialSnapshot]);
The effect runs after onMount. If a persistence listener was registered in onMount, it sees the load as a change event and writes the loaded state back to the backend, which is harmless. But if initialSnapshot is initially null and arrives via prop later, the effect overwrites the user's in-progress edits.
Correct pattern: load the snapshot inside onMount, before any listeners are registered.
4. Shape IDs from Math.random
Claude generates id: \shape:${Math.random().toString(36).slice(2)}``. The store accepts it. Under collaboration, two clients can produce the same suffix, which causes the second-arriving shape to overwrite the first.
Correct pattern: createShapeId() mints a UUID-grade identifier with the correct prefix.
5. Tool registered at the wrong level
Claude generates a StateNode and tries to register it directly on editor.root. The shortcut works, but the tool sits alongside the built-in tools without a parent that owns its child states, which causes selection issues when the user switches between tools.
Correct pattern: register the tool via the tools prop on <Tldraw />, which puts it inside tldraw's root state and inherits the correct child-state plumbing.
6. Store mutated directly from tool
Claude generates editor.store.update(id, (s) => ({ ...s, x: newX })) inside a tool's pointer handler. The mutation works, but it skips the command system that the undo history listens to, so the user cannot undo the change.
Correct pattern: editor.updateShape({ id, type, props: { x: newX } }), which dispatches through the command system and integrates with undo.
Add this list to CLAUDE.md with the corrected form for each.
Permission hooks for tldraw projects
A tldraw project has CLI surface for adding dependencies, running tests, and generating shape scaffolds. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm add tldraw*)",
"Bash(pnpm test*)",
"Bash(pnpm vitest*)",
"Bash(pnpm tsc --noEmit*)"
],
"deny": [
"Bash(pnpm remove tldraw*)",
"Bash(rm -rf src/canvas*)"
]
}
}
Adding tldraw packages and running tests are safe and reversible. Removing the core package or wiping the canvas directory needs explicit confirmation.
Building canvases that survive real users
The tldraw CLAUDE.md in this guide produces canvases where every custom shape has a ShapeUtil registered in the shapeUtils array with getGeometry for hit-testing and an indicator for selection, every tool is a StateNode registered at the root with the correct child states for its interaction, persistence is wired inside onMount so the empty-document race cannot happen, snapshot writes are debounced and filtered by scope so the backend is not flooded, and shape IDs are minted via createShapeId() so collaborative sessions do not collide.
The underlying principle is the same as any complex client-side library with Claude Code. tldraw without a CLAUDE.md produces canvases that look right in demos and corrupt data in production: shapes that do not render because the registry is missing them, documents that revert to empty because the persistence boundary saved an empty state on mount, undo that does not work because tools mutated the store directly, and collaborative edits that overwrite each other because shape IDs were not UUID-clean.
For the React component patterns the canvas depends on, Claude Code with React covers the rendering model that pairs cleanly with editor lifecycles. For the state management layer that holds document metadata around the canvas, Claude Code with Zustand covers the store patterns that work well with editor refs. Claudify includes a tldraw-specific CLAUDE.md template with the shape scaffolding rules, the tool registration pattern, the persistence boundary contract, the shape ID discipline, and all six common-mistake rules pre-configured.
Get Claudify. Ship tldraw canvases that respect the store.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify