Building canvas-based editors in React (Konva patterns)
I've spent a lot of time building floorplan and diagram editors—the kind where users draw shapes, snap them to grids, resize with handles, and expect everything to feel immediate. React Konva is my go-to for this work. It gives you React's component model on top of HTML5 Canvas, which sounds simple until you hit the edge cases.
Here's what I've learned about making these editors actually work in production.
The core tension: React's model vs. imperative canvas
React wants to own the render cycle. Canvas wants you to draw imperatively. React Konva bridges this, but you need to understand where the abstraction leaks.
The biggest trap: treating Konva nodes like DOM elements. They're not. A <Rect> in React Konva doesn't have the same lifecycle guarantees. If you're used to refs "just working," you'll hit issues where your ref is stale or the node hasn't mounted yet.
What works:
- Use
useReffor Konva nodes you need to manipulate imperatively (animations, manual redraws). - Never rely on refs being populated during the first render. Use effects or callbacks.
- Keep the imperative surface small. The more you can express declaratively, the fewer bugs you'll chase.
Event models: pointer, wheel, touch
Canvas events are their own world. You're not in DOM-land—propagation exists but it follows Konva's node tree, and stopping it looks different (e.cancelBubble = true, not stopPropagation()).
Pointer events
Konva gives you onMouseDown, onMouseMove, etc., but I prefer the pointer event equivalents (onPointerDown, etc.) for unified mouse/touch/pen handling. The gotcha: Konva's pointer events fire on shapes, not on empty canvas space by default. For background clicks (deselecting, panning), a reliable approach is to attach onPointerDown to the Stage and check whether the click landed on the stage itself—no full-size rect needed.
<Stage
onPointerDown={(e) => {
const stage = e.target.getStage();
if (e.target === stage) {
// clicked on empty space
deselectAll();
}
}}
>
{/* layers/shapes */}
</Stage>
If you prefer a background rect (e.g. to separate background from stage logic), use fill="rgba(0,0,0,0)" so it's obvious the rect still receives events.
Wheel events (zoom/pan)
Wheel handling is where things get messy. Browsers have different scroll deltas, pinch-to-zoom on trackpads fires wheel events with ctrlKey set, and you'll fight with page scroll if you're not careful. If you want pinch-to-zoom only (and wheel to pan or scroll), gate zoom behind e.evt.ctrlKey and 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 where most bugs hide. You have at least three coordinate systems:
- Screen coordinates: Where the user clicked (e.g.,
clientX,clientY). - Stage coordinates: After accounting for the canvas element's position on the page.
- Canvas/world coordinates: After accounting for zoom and pan (stage scale and position).
Every time you convert between them, there's a chance for off-by-one errors or forgetting a transform.
The conversion pattern I use
// 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 have their own rotation or scale, things compound. Konva's getAbsoluteTransform() and getTransform() are your friends, but they're easy to misuse.
Rule of thumb: if you're doing hit testing or snapping, work in world coordinates. Convert the pointer to world space, do your math there, then set shape positions in world space. Don't try to be clever with relative transforms—you'll regret it.
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 should be fast. If it's not, you're doing something wrong. Here's what I've learned:
Isolate what changes
Konva redraws layers, not individual shapes. If you have a layer with 500 shapes and one of them is animating, all 500 get redrawn.
Solution: put frequently-updating content on its own layer. Selection handles, drag previews, cursors—anything that moves on every 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
1. Command pattern for actions. Wrap every user action (move, resize, delete) in a command object. Makes undo/redo trivial and logging easy.
2. Shape components own their event handlers. Don't have a giant switch statement in the Stage. Let each shape type handle its own interactions.
3. Separate "what" from "how." The React state says "shape X is at position Y." The Konva layer figures out how to draw it. Don't mix concerns.
4. Test with real data. A 10-shape demo works great. Load 500 shapes and see what breaks. Performance problems always show up at scale.
5. Mobile is different. Touch targets need to be bigger, gestures conflict with scroll, and performance budgets are tighter. Test on real devices, not just Chrome DevTools emulation.
Building canvas editors is a deep rabbit hole. React Konva gives you a good starting point, but the real work is in the details—event handling, coordinate math, state architecture, and relentless performance tuning. The patterns here have gotten me through several production editors. Hopefully they save you some of the pain.