Canvas editors in React (Konva patterns)
Building canvas-based editors in React (Konva patterns)
I've built floorplan and diagram editors for years — the kind where users draw shapes, snap to grids, resize with handles, and expect everything to feel instant. React Konva gives you React's component model on top of HTML5 Canvas. That sounds clean until you hit the edge cases.
Here's what I learned making them work in production.
The core tension: declarative vs. imperative
React owns the render cycle. Canvas draws imperatively. React Konva bridges them, but the abstraction leaks.
The biggest trap: treating Konva nodes like DOM elements. A <Rect> in React Konva doesn't share DOM lifecycle guarantees. Refs that "just work" in DOM land come back stale or null here.
What works:
useReffor nodes you manipulate imperatively (animations, manual redraws).- Never trust refs on first render. Use effects or callbacks.
- Keep the imperative surface small. The more you express declaratively, the fewer bugs.
Event models: pointer, wheel, touch
Canvas events are their own world. Propagation follows Konva's node tree, not the DOM. Stop it with e.cancelBubble = true, not stopPropagation().
Pointer events
Prefer onPointerDown (and friends) over onMouseDown — unified mouse/touch/pen. The gotcha: Konva pointer events fire on shapes only, not empty canvas. For background clicks (deselect, pan), attach to the Stage and check if the target is the stage itself:
<Stage
onPointerDown={(e) => {
const stage = e.target.getStage();
if (e.target === stage) deselectAll();
}}
>
{/* layers */}
</Stage>
Wheel events (zoom/pan)
Wheel handling gets messy fast. Browsers disagree on scroll deltas. Trackpad pinch-to-zoom fires wheel events with ctrlKey. Without preventDefault(), the page scrolls under your canvas. Gate zoom behind e.evt.ctrlKey, pan otherwise.
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
const handleWheel = (e) => {
e.evt.preventDefault();
const stage = e.target.getStage();
if (!stage) return;
// Ensure Konva knows current pointer for this wheel event
stage.setPointersPositions(e.evt);
const pointer = stage.getPointerPosition();
if (!pointer) return;
const scaleBy = 1.05;
const oldScale = stage.scaleX();
const direction = e.evt.deltaY > 0 ? -1 : 1;
const nextScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
const newScale = clamp(nextScale, 0.2, 6);
const mousePointTo = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale,
};
stage.scale({ x: newScale, y: newScale });
stage.position({
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
});
stage.batchDraw();
};
Key points:
- Always
preventDefault()or the page scrolls. - Call
stage.setPointersPositions(e.evt)and guard ongetPointerPosition()—it can be null on wheel. - Zoom toward the pointer, not the center—users expect this.
- Clamp scale to sane bounds (e.g. 0.2–6) and call
stage.batchDraw()after updating.
Touch and multi-touch
Touch adds pinch-to-zoom and two-finger pan. I use a dedicated gesture state to track whether we're in a single-touch drag or a multi-touch gesture:
const [gestureState, setGestureState] = useState({
type: null, // 'drag' | 'pinch' | null
initialDistance: null,
initialScale: null,
});
When touchstart fires with two touches, calculate the distance and store it. On touchmove, compare to get scale delta. Don't mix pinch and drag—pick one per gesture.
Coordinate systems and transforms
This is the bug farm. Three coordinate systems, every conversion a chance for off-by-one:
- Screen — where the user clicked (
clientX,clientY). - Stage — relative to the canvas element on the page.
- World — after zoom and pan (stage scale + position).
The pattern
// Screen -> Stage (relative to canvas element)
const stageBox = stageRef.current.container().getBoundingClientRect();
const stageX = clientX - stageBox.left;
const stageY = clientY - stageBox.top;
// Stage -> World (accounting for zoom/pan)
const scale = stageRef.current.scaleX();
const worldX = (stageX - stageRef.current.x()) / scale;
const worldY = (stageY - stageRef.current.y()) / scale;
Transforms on shapes
When shapes carry their own rotation and scale, transforms compound. Konva's getAbsoluteTransform() and getTransform() help, but misuse them and your coordinates silently drift.
Rule: for hit testing or snapping, work in world coordinates. Convert the pointer to world space, do the math there, set positions in world space. Relative transforms are clever until they're wrong.
Selection, snapping, and constraints
Selection
Multi-select is deceptively complex. You need:
- A selection state (array of IDs or a Set).
- Visual feedback (selection outlines, handles).
- Keyboard modifiers (Shift to add, Cmd/Ctrl to toggle).
I keep selection state outside Konva—just IDs in React state. The canvas renders selection UI based on that state. This keeps the source of truth in React and avoids syncing issues.
{selectedIds.map(id => {
const shape = shapes.find(s => s.id === id);
return <SelectionOutline key={id} shape={shape} />;
})}
Resize handles (Transformer)
A lot of people use Konva for editors specifically because of Transformer. Short version: keep the Transformer on a dedicated "UI" layer (same idea as selection outlines). Use boundBoxFunc to enforce minimum size and aspect ratio—clamp the box before Konva applies it so the shape never enters an invalid state. Only sync to React state on onTransformEnd, not on every transform move; that keeps re-renders out of the hot path.
Snapping
Snapping (to grid, to other shapes, to guides) needs to happen during drag, not after. Setting node.position() inside onDragMove can jitter—it fights Konva's internal drag updates. The smoother production pattern is dragBoundFunc: Konva asks you for the allowed position as part of the drag loop.
const snap = (value, gridSize) => Math.round(value / gridSize) * gridSize;
<Rect
draggable
dragBoundFunc={(pos) => ({
x: snap(pos.x, GRID_SIZE),
y: snap(pos.y, GRID_SIZE),
})}
/>
If you also need bounds clamping (e.g. keep shape inside stage), do it in the same function: snap → clamp → return. For snapping to other shapes, precompute snap targets (edges, centers) at drag start and return the snapped position from dragBoundFunc. Keep the candidate list small or you'll tank performance.
Constraints
Constraints (minimum size, aspect ratio, bounds) belong in the drag/resize handlers. Check and clamp before applying the new transform. Don't let the shape get into an invalid state and then try to fix it—that causes flicker.
Keeping interactions at 60fps
Canvas is supposed to be fast. Here's what keeps it that way at scale.
Isolate what changes
Konva redraws entire layers, not individual shapes. One animating shape in a 500-shape layer = 500 redraws. Put frequently-updating content on its own layer — selection handles, drag previews, anything that moves per frame.
<Stage>
<Layer>
{/* Static shapes */}
</Layer>
<Layer>
{/* Selection UI, guides, transient stuff */}
</Layer>
</Stage>
Avoid React re-renders during drag
If every onDragMove fires a React state update, you're forcing a full component re-render at 60fps. That's too slow for complex editors.
Instead, update Konva nodes directly during drag (using refs or e.target), and only sync to React state on onDragEnd.
const handleDragEnd = (e) => {
const id = e.target.id();
const newPos = { x: e.target.x(), y: e.target.y() };
setShapes(prev => prev.map(s => s.id === id ? { ...s, ...newPos } : s));
};
During drag, Konva handles the visual update. React only hears about the final position.
Batch expensive calculations
If snapping or constraint logic is expensive, debounce or throttle it. Or, compute snap targets once at drag start and reuse them.
Konva-specific knobs at scale
Three toggles that matter when you have hundreds of shapes:
listening={false}on layers or shapes that don't need hit-testing—reduces hit graph work.perfectDrawEnabled={false}on shapes that don't need pixel-perfect strokes—skips extra draw passes.transformsEnabled="position"on nodes that only move (no rotate/scale)—huge win when you have lots of nodes.
These are the kinds of things that make a "500 shapes" demo stop stuttering.
Watch for memory leaks
Konva nodes can leak if you're not careful with cleanup. Always remove event listeners and destroy nodes when unmounting. React Konva handles most of this, but if you're doing imperative stuff, clean up in useEffect return functions.
Modeling editor state
This is where architecture matters. A complex editor has a lot of state:
- Document state: The shapes, their positions, properties. This is what gets saved.
- Interaction state: What's selected, what's being dragged, current tool, zoom level. This is ephemeral.
- UI state: Sidebar open, panel visibility, modal state. Not canvas-related but affects the editor.
Keep them separate
I structure state like this:
const [doc, setDoc] = useState({ shapes: [], guides: [] });
const [interaction, setInteraction] = useState({
selectedIds: [],
tool: 'select',
dragState: null,
});
const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 });
Document state is the source of truth. Interaction state can be reconstructed (if you lose selection, it's annoying but not data loss). Viewport state is per-session.
Normalize document state when the editor grows—especially for undo/redo and selection rendering. Instead of a flat shapes: [] array, use shapesById: Record<string, Shape> plus shapeOrder: string[]. That avoids find() in render loops and makes "which shapes are selected" a cheap lookup.
Serialization
For save/load, serialize only document state. Don't save selection or viewport—let users start fresh. Version your format: even if you stay with JSON, add a version: 1 (or similar) and a tiny migration switch when loading. When you change the schema later, you'll thank yourself.
const serialize = () => JSON.stringify({ version: 1, document: doc });
const deserialize = (json) => {
const data = JSON.parse(json);
if (data.version === 1) setDoc(data.document);
else if (data.version === undefined) setDoc(migrateV0(data));
else throw new Error(`Unsupported version: ${data.version}`);
};
For undo/redo, snapshot document state. I use a simple history array with a pointer. Don't try to implement operational transforms unless you really need collaborative editing.
Debugging
When something goes wrong, you need to see state. I add a debug panel (hidden behind a keyboard shortcut) that dumps:
- Current selection
- Shape under pointer
- Viewport transform
- Last N actions
This saves hours of console.log archaeology.
Patterns I keep coming back to
-
Command pattern for actions. Wrap every user action in a command object. Undo/redo becomes trivial. Logging becomes free.
-
Shape components own their event handlers. No giant switch in the Stage. Each shape type handles its own interactions.
-
Separate "what" from "how." React state says "shape X at position Y." Konva figures out the drawing. Don't mix them.
-
Test with real data. 10 shapes? Everything works. 500 shapes? That's where the bugs are. Test at scale.
-
Mobile is different. Bigger touch targets. Gestures fight scroll. Tighter performance budgets. Test on real devices, not emulation.
Canvas editors are a deep rabbit hole. React Konva handles the starting point, but the real work lives in event handling, coordinate math, state architecture, and relentless performance tuning. These patterns have carried me through several production editors.