flowchart LR
subgraph DOM["DOM Rendering"]
D1["1,000 divs"]
D2["Layout calculation"]
D3["Event listeners"]
D4["~15 FPS 😰"]
end
subgraph Canvas["Canvas Rendering"]
C1["1,000 shapes"]
C2["Pure math"]
C3["Single event handler"]
C4["~60 FPS 🚀"]
end
style D4 fill:#ef4444
style C4 fill:#10b981
High-Performance Canvas: Breaking the DOM Limit
React makes building UIs easy because it abstracts the DOM. But the DOM is heavy. Each <div> is an object with hundreds of properties, event listeners, and layout constraints.
When you’re building a tool like a node-based editor, knowledge graph visualizer, or real-time data dashboard—React components will start to lag around 1,000+ elements.
The solution is to drop down to the Canvas API.
When to Use Canvas
| Scenario | DOM | Canvas |
|---|---|---|
| Forms, buttons, text inputs | ✅ | ❌ |
| Lists with < 500 items | ✅ | ❌ |
| Node graphs (100+ nodes) | ❌ | ✅ |
| Real-time charts | ⚠️ | ✅ |
| Particle effects | ❌ | ✅ |
| Image manipulation | ❌ | ✅ |
| Infinite canvas (Figma-style) | ❌ | ✅ |
The Hybrid Approach
We don’t abandon React. We use React for the chrome (buttons, sidebars, modals) and Canvas for the stage (the main visualization area).
function App() {
return (
<div className="app">
{/* React for UI chrome */}
<Sidebar />
<Toolbar />
{/* Canvas for performance-critical visualization */}
<CanvasStage nodes={nodes} edges={edges} />
{/* React for overlays */}
<ContextMenu />
<NodeInspector />
</div>
);
}
The Rendering Loop
In React, updates happen when props/state change. In Canvas, updates happen in a requestAnimationFrame loop (60fps). We need a bridge.
Basic Canvas Component
import { useRef, useEffect, useCallback } from 'react';
interface Node {
id: string;
x: number;
y: number;
label: string;
color: string;
}
interface CanvasStageProps {
nodes: Node[];
width?: number;
height?: number;
}
export function CanvasStage({ nodes, width = 800, height = 600 }: CanvasStageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const nodesRef = useRef(nodes);
// Keep ref in sync without restarting loop
useEffect(() => {
nodesRef.current = nodes;
}, [nodes]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
let animationId: number;
const render = () => {
// 1. Clear screen
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 2. Draw background grid
drawGrid(ctx, canvas.width, canvas.height);
// 3. Draw nodes (pure math, no DOM overhead!)
nodesRef.current.forEach(node => {
drawNode(ctx, node);
});
// 4. Continue loop
animationId = requestAnimationFrame(render);
};
render();
return () => cancelAnimationFrame(animationId);
}, []); // Empty deps = single loop instance
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{ border: '1px solid #333' }}
/>
);
}
function drawGrid(ctx: CanvasRenderingContext2D, width: number, height: number) {
ctx.strokeStyle = '#2a2a2a';
ctx.lineWidth = 1;
const gridSize = 20;
for (let x = 0; x <= width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y <= height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
}
function drawNode(ctx: CanvasRenderingContext2D, node: Node) {
const radius = 30;
// Shadow
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
ctx.shadowBlur = 10;
ctx.shadowOffsetY = 4;
// Circle
ctx.fillStyle = node.color;
ctx.beginPath();
ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI);
ctx.fill();
// Reset shadow
ctx.shadowColor = 'transparent';
// Label
ctx.fillStyle = 'white';
ctx.font = '12px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(node.label, node.x, node.y);
}
Hit Testing: Interacting with Pixels
Since we don’t have DOM elements, we don’t have onClick. We have coordinates.
interface CanvasStageProps {
nodes: Node[];
onNodeClick?: (node: Node) => void;
onNodeDrag?: (node: Node, x: number, y: number) => void;
}
export function CanvasStage({ nodes, onNodeClick, onNodeDrag }: CanvasStageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [isDragging, setIsDragging] = useState(false);
const getMousePos = (e: React.MouseEvent) => {
const rect = canvasRef.current!.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
const findNodeAt = (x: number, y: number): Node | undefined => {
// Check in reverse order (top nodes first)
return [...nodes].reverse().find(node => {
const dist = Math.sqrt((x - node.x) ** 2 + (y - node.y) ** 2);
return dist < 30; // 30 = node radius
});
};
const handleMouseDown = (e: React.MouseEvent) => {
const { x, y } = getMousePos(e);
const node = findNodeAt(x, y);
if (node) {
setSelectedNode(node);
setIsDragging(true);
onNodeClick?.(node);
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isDragging || !selectedNode) return;
const { x, y } = getMousePos(e);
onNodeDrag?.(selectedNode, x, y);
};
const handleMouseUp = () => {
setIsDragging(false);
};
return (
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isDragging ? 'grabbing' : 'default' }}
/>
);
}
Pan & Zoom: Infinite Canvas
For Figma-style infinite canvases, we need to track camera position:
interface Camera {
x: number;
y: number;
zoom: number;
}
function useCamera() {
const [camera, setCamera] = useState<Camera>({ x: 0, y: 0, zoom: 1 });
const pan = useCallback((dx: number, dy: number) => {
setCamera(c => ({ ...c, x: c.x + dx, y: c.y + dy }));
}, []);
const zoomTo = useCallback((factor: number, centerX: number, centerY: number) => {
setCamera(c => {
const newZoom = Math.max(0.1, Math.min(5, c.zoom * factor));
// Zoom towards mouse position
const zoomDelta = newZoom - c.zoom;
const offsetX = (centerX - c.x) * (zoomDelta / c.zoom);
const offsetY = (centerY - c.y) * (zoomDelta / c.zoom);
return {
x: c.x - offsetX,
y: c.y - offsetY,
zoom: newZoom,
};
});
}, []);
return { camera, pan, zoomTo };
}
Applying Camera Transform
useEffect(() => {
const render = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Apply camera transform
ctx.save();
ctx.translate(camera.x, camera.y);
ctx.scale(camera.zoom, camera.zoom);
// Draw everything in world space
drawGrid(ctx, 10000, 10000);
nodes.forEach(node => drawNode(ctx, node));
ctx.restore();
animationId = requestAnimationFrame(render);
};
}, [camera]);
Connecting Nodes: Drawing Edges
interface Edge {
id: string;
from: string;
to: string;
}
function drawEdges(
ctx: CanvasRenderingContext2D,
edges: Edge[],
nodes: Node[]
) {
const nodeMap = new Map(nodes.map(n => [n.id, n]));
edges.forEach(edge => {
const from = nodeMap.get(edge.from);
const to = nodeMap.get(edge.to);
if (!from || !to) return;
// Bezier curve for smooth connections
const midX = (from.x + to.x) / 2;
ctx.strokeStyle = '#666';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.bezierCurveTo(
midX, from.y, // Control point 1
midX, to.y, // Control point 2
to.x, to.y // End point
);
ctx.stroke();
// Arrow head
drawArrowHead(ctx, midX, to.y, to.x, to.y);
});
}
function drawArrowHead(
ctx: CanvasRenderingContext2D,
fromX: number, fromY: number,
toX: number, toY: number
) {
const angle = Math.atan2(toY - fromY, toX - fromX);
const size = 10;
ctx.fillStyle = '#666';
ctx.beginPath();
ctx.moveTo(toX, toY);
ctx.lineTo(
toX - size * Math.cos(angle - Math.PI / 6),
toY - size * Math.sin(angle - Math.PI / 6)
);
ctx.lineTo(
toX - size * Math.cos(angle + Math.PI / 6),
toY - size * Math.sin(angle + Math.PI / 6)
);
ctx.closePath();
ctx.fill();
}
Performance Optimization
Dirty Rectangles
Only redraw what changed:
const dirtyRegions = useRef<Set<string>>(new Set());
function markDirty(nodeId: string) {
dirtyRegions.current.add(nodeId);
}
const render = () => {
if (dirtyRegions.current.size === 0) {
animationId = requestAnimationFrame(render);
return; // Nothing to update
}
// Only redraw dirty regions
dirtyRegions.current.forEach(nodeId => {
const node = nodes.find(n => n.id === nodeId);
if (node) {
// Clear just this node's area
ctx.clearRect(node.x - 40, node.y - 40, 80, 80);
drawNode(ctx, node);
}
});
dirtyRegions.current.clear();
animationId = requestAnimationFrame(render);
};
Spatial Indexing
For thousands of nodes, use a quadtree:
import { Quadtree, Rectangle } from 'quadtree-lib';
const quadtree = new Quadtree({
width: 10000,
height: 10000,
maxElements: 10,
});
// Only query visible nodes
function getVisibleNodes(camera: Camera, viewportWidth: number, viewportHeight: number) {
const visibleBounds = new Rectangle(
-camera.x / camera.zoom,
-camera.y / camera.zoom,
viewportWidth / camera.zoom,
viewportHeight / camera.zoom
);
return quadtree.query(visibleBounds);
}
WebGL & WebGPU: The Next Level
While Canvas 2D handles most cases, WebGL/WebGPU gives you GPU acceleration:
| Technology | Use Case | Complexity |
|---|---|---|
| Canvas 2D | 1,000-10,000 shapes | Low |
| WebGL | 10,000-100,000 shapes | Medium |
| WebGPU | 100,000+ shapes, 3D | High |
Libraries that abstract the complexity: - PixiJS — 2D WebGL with Canvas fallback - Three.js — 3D rendering - Deck.gl — Large-scale data visualization
Separate your Data Model (Zustand store) from your View Layer (Canvas renderer). This lets you: - Render the same data as a list (React) or graph (Canvas) - Persist state independently of rendering - Test business logic without graphics
By mastering Canvas, you unlock the ability to build tools that feel native—snappy, responsive, and capable of handling the complexity that serious users demand.