The canvas is everything in Codelit. It's where AI text becomes a thing you can see, drag around, and actually reason about. If the canvas sucks, the product sucks. No amount of AI magic saves a bad visual experience.
React Flow
is a React library for node-based interfaces that renders every node as a real React component, so a diagram node can hold a form, an icon, or a live status badge instead of being a rectangle drawn onto a bitmap.
I spent more time on the canvas than on any other part of the product, which is fitting given that the whole thing started as a Lucidchart rage-quit. React Flow (@xyflow/react) made it possible without me having to build a rendering engine from scratch. But "possible" and "easy" are very different words.
Why React Flow instead of D3, Cytoscape, or raw canvas?
React Flow won because every node stays a React component with full access to hooks, state, and context. The three alternatives I evaluated each broke that in a different way: raw canvas gives you pixels and no interaction primitives, D3 wants the DOM for itself, and Cytoscape renders nodes as bitmaps.
| Option | How nodes render | What it would have cost me | Verdict |
|---|---|---|---|
| Raw HTML5 Canvas | Pixels I draw myself | Zoom, pan, marquee selection, keyboard navigation, hit testing on overlapping nodes, accessibility | A multi-month project for a team, not a weekend hack for a solo dev |
| D3.js | DOM, owned by D3 | A D3 island sitting completely outside the React lifecycle | Updating a node label means imperatively selecting a DOM element and calling .text() instead of just setting state. Going backwards |
| Cytoscape.js | Canvas-rendered pixels | Nothing up front, everything later | You can't put a dropdown menu in a pixel |
| React Flow (@xyflow/react) | Real React components | Bundle size, and layout is still entirely your problem | Shipped |
I did prototype the raw canvas version over a weekend. Drawing rectangles and lines was fine. Then I started listing what still had to exist before anyone could actually use the thing, and killed it.
The rest of the case for React Flow is unglamorous and decisive: pan, zoom, minimap, selection and keyboard shortcuts work out of the box; every node type, edge type and event handler is properly typed; and the maintainers at xyflow are active. I've opened 3 issues and all got responses within a day.
What goes inside a custom node?
A Codelit node is a React component that renders an icon, a label, a metadata line, and the handles edges connect to. Everything it shows comes from the node's data object, including the status that decides whether it draws as healthy, degraded, or failed.
Codelit has 7 custom node types. Each one represents a different infrastructure component with its own visual style and interactive behavior.
That status field is what chaos mode drives when it kills a component and watches the failure spread. A pixel can't render its own failure badge.
The full list of node types: ServiceNode, DatabaseNode, APINode, QueueNode, CacheNode, LoadBalancerNode, CDNNode, and ExternalServiceNode. They all follow the same pattern: a wrapper div with status-aware styling, icon, label, metadata, and handles for connecting edges.
Registering custom nodes with React Flow is straightforward:
How do you show sync and async traffic on the same canvas?
Codelit encodes traffic type in the edge itself: blue solid for synchronous HTTP calls, amber dashed for async queue messages, green dotted for pub/sub events. Users can tell at a glance which connections are sync and which are async without reading a single label.
Static arrows are boring. When data flows through your architecture, you want to see it move. The motion is a marching dash offset in pure CSS, with no JavaScript running per frame:
The hardest problem: AI output to positioned nodes
The AI returns a JSON structure describing components and their connections, and it knows nothing about pixel coordinates, visual spacing, or where anything belongs on a 1200x800 canvas. Converting that into a well-laid-out diagram was the hardest problem in the entire product.
Nothing upstream helps. The pipeline that produces that JSON deals in components and edges, not geometry, and the same problem lands on you whether the nodes arrived from a prompt or from Mermaid syntax pasted into the editor.
The layout algorithm went through 6 rewrites. Here's the brief history of my suffering.
| Attempt | Layout approach | What happened |
|---|---|---|
| 1 | Grid rows and columns | Looked like a spreadsheet. No visual hierarchy. Killed it |
| 2 | Force-directed (d3-force) | Nodes overlapped constantly and the layout was non-deterministic. Same input, different positions every time. Users hated that |
| 3 | Random with collision avoidance | Worse than the grid. Don't ask |
| 4 | Manual heuristics: load balancers top, services middle, databases bottom | Worked for simple architectures, completely fell apart for anything with more than 10 nodes |
| 5 | Dagre, default config | The breakthrough. Layouts finally looked like real architecture diagrams, but the default spacing was too tight and edge crossings were common |
| 6 | Dagre with a custom config | This is what ships |
Dagre is a directed graph layout library that produces layered hierarchical layouts, which is exactly the shape an architecture diagram wants. The shipping config sets rankdir to top-to-bottom, opens up nodesep and ranksep past defaults that were too tight, and post-processes the result to centre orphan nodes and align nodes of the same type.
How do you make every canvas edit undoable?
Every user action that changes the canvas needs to be reversible: moving a node, adding an edge, editing a label, deleting a component. Codelit does this with a history stack in Zustand holding three fields, past, present and future, where each entry is a full snapshot.
A CanvasSnapshot is a serialized copy of all nodes and edges. I debounce pushes during drag operations so dragging a node only creates one undo entry, not one for every mouse move event.
It's the least glamorous code in the product, in the same category as the ⌘K command palette: nobody praises it, everybody notices its absence.
What actually makes a React Flow canvas slow?
Re-renders, not node count. React Flow already virtualizes nodes outside the viewport, but it passes every visible node a new position object on every frame, so an unmemoized custom node component re-renders continuously for the duration of a pan gesture. Three fixes cover almost all of it.
Memoize custom nodes. Every custom node component is wrapped in React.memo with a custom comparator that only re-renders when data, selected, or status actually change.
Batch state updates. When the AI generates a 30-node diagram, I don't add nodes one at a time, which would trigger 30 re-renders. I set the entire node array in one setState call.
Lazy load the canvas. React Flow isn't a tiny dependency, so I dynamically import it and keep it off the initial page load for users who land on the marketing page. That discipline matters more once the app is installed as a PWA and opened cold, where there's no browser chrome to hide a slow start behind.
For diagrams under 50 nodes, which covers 95% of use cases, everything stays above 60fps during pan and zoom. Above 50 nodes there's a slight hitch on low-end devices during rapid zooming, but it's usable.
Where can you try the canvas yourself?
The best way to understand the canvas is to use it. Go to Codelit, type an architecture description, and watch it generate. Drag nodes around. Click edges. Try the minimap. Or start from a pre-built template and modify it.
If you're building your own node-based UI with React Flow, I hope this saves you some of the trial and error I went through. The library is excellent. Most of the hard problems are in the layout algorithm and the data model, not in the rendering, which means the library choice buys you less than it feels like it should. Get layout and data right and React Flow handles the rest.
Questions people actually ask
- Should I use React Flow or D3 to build a diagram editor?
- React Flow is the better choice when every node needs to be a real interface element rather than a drawing. D3 expects to own the DOM, which fights React for control and pushes you back into imperative updates. Pick D3 when the output is a static or purely data-driven visualisation, and React Flow when nodes have to hold forms, buttons, menus, or live status indicators inside them.
- How do you automatically position nodes in a React Flow diagram?
- React Flow does not lay out nodes for you. It renders whatever coordinates you hand it, so the positioning is your problem. Codelit uses Dagre, a directed graph layout library that produces layered hierarchical layouts. Every node and edge is added to a Dagre graph, the layout runs, and the resulting coordinates are mapped back onto the React Flow nodes with a half-width and half-height offset so each node sits centred.
- Why does force-directed layout fail for architecture diagrams?
- Force-directed layout treats edges as springs and nodes as particles that repel each other. It demos beautifully and then fails on real architecture. In Codelit it produced constant node overlap, and worse, it was non-deterministic, so generating the same architecture twice gave different positions each time. Users read that as the tool being broken. Layered hierarchical layout fits better because architecture has an obvious top-to-bottom direction.
- How many nodes can React Flow handle before it gets slow?
- In Codelit, diagrams under fifty nodes stay above sixty frames per second during panning and zooming, which covers about ninety-five percent of real use. Above fifty nodes there is a slight hitch on low-end devices during rapid zooming, but it remains usable. The bigger lever is memoisation. Without a memo comparator on custom node components, every visible node re-renders on every frame of a pan.
- Can you add undo and redo to a React Flow canvas?
- Yes, and the pattern that works is a history store holding three things: the past states, the present state, and the future states. Every action that mutates the canvas pushes a serialized snapshot of all nodes and edges into the past and clears the future. Codelit uses Zustand for this and debounces pushes during drag, so dragging a node creates one undo entry instead of one per mouse move.