/ 3 min read
Drag, connect, publish: building a visual flow editor in React
What it takes to build a node based editor that people trust with production APIs: a graph model kept apart from the canvas, validation as you draw, undo that always works, and versions you can roll back.
- React
- Product engineering
- UX
One of the products I led the frontend for is an API management platform where an API is assembled from nodes: receive a request, transform it, call a backend, respond. Teams drag nodes onto a canvas, connect them, and a new endpoint becomes a configuration change rather than a development project.
A demo of a node editor takes an afternoon. An editor that people use to change APIs their customers depend on takes a lot more. These are the principles I build editors like this on.
The graph is the product, the canvas is a view
The first rule: the flow is a plain data structure, and the canvas only renders it. Nodes, their configuration and the connections between them live in one serialisable model. Positions on the canvas are stored too, but as presentation, separate from what the flow means.
type Flow = {
nodes: Record<string, FlowNode>
edges: { id: string; from: Port; to: Port }[]
layout: Record<string, { x: number; y: number }>
}
type FlowNode =
| { id: string; kind: 'request'; method: HttpMethod; path: string }
| { id: string; kind: 'transform'; mapping: Mapping }
| { id: string; kind: 'http'; target: string; timeoutMs: number }
| { id: string; kind: 'database'; connection: string; query: ParameterisedQuery }
| { id: string; kind: 'response'; status: number }Keeping the model independent pays off everywhere. The server validates and runs exactly the structure the editor saves. Tests create flows without rendering anything. The canvas library can be swapped without a migration. And features like copy and paste, templates and diffs are just operations on data.
Every change is a command
People experiment in an editor, and they only experiment freely when undo always works. So no part of the UI mutates the flow directly. Every change, whether adding a node, moving it, connecting two ports or editing a field, is a small command applied by one reducer, and the history is a list of those commands.
type Command =
| { type: 'addNode'; node: FlowNode; at: Point }
| { type: 'moveNodes'; ids: string[]; by: Point }
| { type: 'connect'; edge: Edge }
| { type: 'updateNode'; id: string; patch: Partial<FlowNode> }
| { type: 'remove'; ids: string[] }
function apply(flow: Flow, command: Command): Flow {
switch (command.type) {
// each case returns a new Flow and never mutates the old one
}
}Two details make undo feel right. A drag produces one moveNodes command when the pointer is released, not one per frame, so a single undo puts the node back where it started. And typing into a field is merged into one command per field until focus leaves it, so undo does not step back one character at a time.
Validate while people draw
A flow that cannot run should never get as far as a failed deployment. The editor validates continuously and shows problems where they are: a red port on a node that is not connected, a field marked when a mapping refers to a value that does not exist upstream, a warning on a backend call without a timeout.
Validation runs on the model, not on the canvas, so the same rules run in the browser as you draw and again on the server before anything is published. The editor also prevents what it can instead of reporting it: a connection that would create a loop simply does not snap, and ports only accept connections of a compatible type.
Draft, publish and roll back
Changing a live API is serious, so editing and publishing are separate steps. Edits go to a draft that autosaves. Publishing freezes a version, and the running API always points at a published version, never at the draft. If something goes wrong, rolling back means pointing at the previous version again, which takes seconds and needs no one to remember what they changed.
Because the flow is data, the publish screen can show a real diff between the draft and the live version: nodes added, removed and changed, with their fields. Reviewers approve changes they can read, not a screenshot of a canvas.
Performance on a large canvas
- Render nodes from narrow selectors. A node subscribes to its own data only, so editing one node does not re-render the rest.
- Keep panning off React. Pan and zoom update a CSS transform on one container, outside of React state, so moving the view never re-renders a node.
- Draw edges cheaply. Edges are SVG paths computed from port positions, and only edges connected to a moving node are recalculated during a drag.
- Load node editors on demand. The configuration panel for a database node is only downloaded when someone opens one.
Make the hidden parts visible
A visual editor hides code, which is the point, but it should not hide behaviour. Each node shows what it will do in a short summary, a test panel runs the flow with a sample request and highlights the path it took, and the published API links to its usage and error rates in production. People trust an editor when they can see what their flow does, before and after they publish it.
Next article ↗
A design system for products people use all day