Mo Sharif
← ~/blog

React Flow Architecture Canvas: Custom Nodes, Dagre Layout

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.

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.

OptionHow nodes renderWhat it would have cost meVerdict
Raw HTML5 CanvasPixels I draw myselfZoom, pan, marquee selection, keyboard navigation, hit testing on overlapping nodes, accessibilityA multi-month project for a team, not a weekend hack for a solo dev
D3.jsDOM, owned by D3A D3 island sitting completely outside the React lifecycleUpdating a node label means imperatively selecting a DOM element and calling .text() instead of just setting state. Going backwards
Cytoscape.jsCanvas-rendered pixelsNothing up front, everything laterYou can't put a dropdown menu in a pixel
React Flow (@xyflow/react)Real React componentsBundle size, and layout is still entirely your problemShipped

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.

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.

Tsx
import { Handle, Position, type NodeProps } from "@xyflow/react";

interface DatabaseNodeData {
  label: string;
  dbType: "postgres" | "mysql" | "mongodb" | "redis" | "dynamodb";
  replicas: number;
  status: "healthy" | "degraded" | "failed";
}

function DatabaseNode({ data, selected }: NodeProps<DatabaseNodeData>) {
  return (
    <div className={`node-database ${data.status} ${selected ? "selected" : ""}`}>
      <Handle type="target" position={Position.Top} />

      <div className="node-icon">
        <DatabaseIcon type={data.dbType} />
      </div>
      <div className="node-label">{data.label}</div>
      <div className="node-meta">
        {data.dbType} &middot; {data.replicas} replica{data.replicas !== 1 ? "s" : ""}
      </div>

      {data.status === "failed" && <div className="failure-badge">FAILED</div>}

      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

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:

Tsx
const nodeTypes = {
  service: ServiceNode,
  database: DatabaseNode,
  api: APINode,
  queue: QueueNode,
  cache: CacheNode,
  loadBalancer: LoadBalancerNode,
  cdn: CDNNode,
  external: ExternalServiceNode,
};

function Canvas() {
  const { nodes, edges } = useCanvasStore();

  return <ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView />;
}

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:

Css
.react-flow__edge-path {
  stroke-dasharray: 8 4;
  animation: flowAnimation 1.5s linear infinite;
}

@keyframes flowAnimation {
  from {
    stroke-dashoffset: 24;
  }
  to {
    stroke-dashoffset: 0;
  }
}

.edge-sync .react-flow__edge-path {
  stroke: #3b82f6; /* blue for synchronous */
}

.edge-async .react-flow__edge-path {
  stroke: #f59e0b; /* amber for async/queue-based */
  stroke-dasharray: 4 8; /* different dash pattern */
}

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.

AttemptLayout approachWhat happened
1Grid rows and columnsLooked like a spreadsheet. No visual hierarchy. Killed it
2Force-directed (d3-force)Nodes overlapped constantly and the layout was non-deterministic. Same input, different positions every time. Users hated that
3Random with collision avoidanceWorse than the grid. Don't ask
4Manual heuristics: load balancers top, services middle, databases bottomWorked for simple architectures, completely fell apart for anything with more than 10 nodes
5Dagre, default configThe breakthrough. Layouts finally looked like real architecture diagrams, but the default spacing was too tight and edge crossings were common
6Dagre with a custom configThis 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.

Typescript
import dagre from "dagre";

function layoutNodes(nodes: Node[], edges: Edge[]): Node[] {
  const g = new dagre.graphlib.Graph();
  g.setDefaultEdgeLabel(() => ({}));
  g.setGraph({
    rankdir: "TB",
    nodesep: 80,
    ranksep: 100,
    marginx: 40,
    marginy: 40,
  });

  nodes.forEach((node) => {
    g.setNode(node.id, { width: 200, height: 80 });
  });

  edges.forEach((edge) => {
    g.setEdge(edge.source, edge.target);
  });

  dagre.layout(g);

  return nodes.map((node) => {
    const pos = g.node(node.id);
    return {
      ...node,
      position: { x: pos.x - 100, y: pos.y - 40 }, // center offset
    };
  });
}

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.

Typescript
interface HistoryState {
  past: CanvasSnapshot[];
  present: CanvasSnapshot;
  future: CanvasSnapshot[];
  pushState: (snapshot: CanvasSnapshot) => void;
  undo: () => void;
  redo: () => void;
}

const useHistoryStore = create<HistoryState>((set, get) => ({
  past: [],
  present: initialSnapshot,
  future: [],
  pushState: (snapshot) =>
    set({
      past: [...get().past, get().present],
      present: snapshot,
      future: [], // clear redo stack on new action
    }),
  undo: () => {
    const { past, present } = get();
    if (past.length === 0) return;
    set({
      past: past.slice(0, -1),
      present: past[past.length - 1],
      future: [present, ...get().future],
    });
  },
  redo: () => {
    const { future, present } = get();
    if (future.length === 0) return;
    set({
      past: [...get().past, present],
      present: future[0],
      future: future.slice(1),
    });
  },
}));

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.

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.

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.