The flow builder canvas
React Flow is a rendering library; the graph is the model (see
flow-graph-model). Keep the mapping in exactly two total functions —
graphToFlow and serializeGraph — and use them everywhere. serializeGraph
is read by save, the dirty check and draft persistence; if those
three serialize differently the "Unsaved" badge lies.
The card
┌─────────────────────────────────┐ 3px left rule = the step's family colour
│ DECIDE rule_a1f2 │ family label · short node id
│ Rule branch │ the step's name
│ 3 rules │ one-line summary from its own config
├───────────────┬─────────────────┤ outlet footer, one name per slice
│ promo │ default │
└───────•───────┴────────•────────┘ one source handle per outlet
- The left rule carries the category colour, defined in one place and read by the card, the palette row, the inspector's swatch and the status bar legend — so a step's colour can never disagree with itself across the four surfaces showing it at once.
- The summary is derived from config, and prefers a count to a truncated
list:
6 eventsbeatsorders/create, orders/pa…. - Diff state rides on the border colour, not a ring or a glow — a second radius around a 4px card reads as a mistake.
- The trigger has no target handle and is not deletable: a flow with no entry point can never run.
One outlet, one handle, one edge
/** Outlets are SOURCE HANDLES. `'out'` ⇄ `null` round-trips through here. */
export const branchToHandle = (branch: string | null): string => branch ?? "out";
export const handleToBranch = (h?: string | null): string | null =>
h && h !== "out" ? h : null;
Every outlet from outletsFor gets a handle, a name, a plus button and a
column, whether or not anything hangs off it. Place them all with the same
rule — slice midpoints, ((i + 0.5) / count) * 100 — so the name, the dot,
the plus and the connector leaving it are one vertical. ((i + 1) / (count + 1)
packs handles toward the middle of the card and points both connectors at the
centre exactly as they are trying to separate.)
A drag-connect replaces any edge already on that handle:
setEdges((es) => addEdge(newEdge, es.filter(
(e) => !(e.source === c.source && e.sourceHandle === c.sourceHandle))));
Geometry in one place
export const CARD_W = 284;
export const COL_GAP = CARD_W + 236; // 236px of daylight between parallel paths
export const ROW_GAP = 240; // row pitch — must clear the tallest thing
// that sits in the gap (the step picker)
/** How far an outlet's column sits from the middle of its card. */
export function outletColumnOffset(index: number, count: number): number {
return count <= 1 ? 0 : (Math.max(0, index) - (count - 1) / 2) * COL_GAP;
}
/** Where a new child belongs: under its own outlet's column, one row down. */
export function childPosition(source: Node, branch: string | null) { /* … */ }
Four things must land on the same vertical: the card a step is added to, the picker that opens before it, the position the step lands at, and the tidy layout that places it afterwards. One definition is how they agree.
Adding a step: aim, then choose, then land
The interaction is three beats, and nothing touches the graph until the third.
1 · Aim. The plus on an outlet (hover-only, so a settled canvas is cards and
connectors) sets aim = { nodeId, branch } and does nothing else — no
placeholder, no panel, no selection. A plus on a connector aims the outlet
that connector hangs off, so there is one way to add a step and one thing it
does. Stop the click propagating, or React Flow's own node handler selects the
card and clears the aim you just set.
2 · Choose a family. A pill opens in the gap under the aimed outlet — Choose next step · promo — with two buttons: Action and Condition. Two buttons, not the eighteen-entry catalog: the split worth putting on the canvas is the one people think in — does this do a thing, or does it split the path. The catalog belongs in the rail, filtered to the family they picked.
export type StepKind = "action" | "conditional";
export function kindOfBlock(def: BlockDef): StepKind {
const branches = def.outlets === "dynamic" || def.outlets.length > 1;
// Waits and agents branch too, but on a runtime outcome rather than on a
// condition you author — those read as actions to the person building.
return branches && def.category === "logic" ? "conditional" : "action";
}
Derive append vs insert from the graph rather than offering two affordances that read identically:
const existing = edgeOnOutlet(edgesRef.current, nodeId, branch);
if (existing) startInsert(existing.id, kind);
else startAppend(nodeId, branch, kind);
3 · Reserve the slot. A placeholder node — deletable: false, dashed, "Pick
an action from the panel" — holds the position the step will occupy, so you can
see exactly where it lands before there is a step to land.
- Append: placeholder at
childPosition(source, branch), plus one dashed ghost edge from the outlet. - Insert: store the split edge verbatim, shift the target's whole subtree
down one
ROW_GAP, remember what moved, and draw two ghost edges.
Then landing a block is a rename in place — the placeholder becomes the node,
and one wire() rebuilds the edges. Cancelling must be a perfect no-op:
stripPlaceholder puts the original edge back verbatim and un-shifts exactly
what it shifted.
While the picker is open, shift the affected subtree in the render copy only:
// Positions ARE part of the saved graph — shifting the real ones would mark a
// flow unsaved just for opening a menu.
...(moved ? { position: { ...n.position, y: n.position.y + ROW_GAP },
draggable: false } : null)
Landing with no placeholder (someone used the panel without aiming) appends to the deepest free outlet — "the end of the flow" — in one pass. Opening a placeholder and immediately filling it reads the pre-update graph and lands nothing.
Layout is the author's
Positions are saved, versioned and diffed. So:
- Mutations place only the card they add, under its own outlet's column, and move nothing else. Splicing a row in shifts the subtree below by exactly one row so the copy does not land on top of the step it was spliced in front of.
- Re-layout only when asked. A
formatRequestcounter (not a flag — pressing Format twice has to work) drivesrelayout, anchored on the trigger's existing position so the flow reflows around it instead of jumping. - The one exception: a graph where every card sits at the same point — what arrives from a flow built through the API with no positions — is laid out once on first sight. Anything with real positions is left exactly as saved.
relayoutwalks trigger first, then true roots, then the rest, and reserves one column per outlet so fillingdefaultafterpromodrops the card beside its sibling instead of repacking the subtree sideways. A node with nothing hanging off it is a leaf, however many outlets it declares — charging it a column per outlet spreads a fork 780px wide and marooned the card in the gap it created.
Repair edges whose outlet no longer exists
A rule's outlets are its branch labels, so one keystroke in the inspector can break an edge with no structural edit happening. That state is worse than it looks: the layout cannot tell which outlet the edge belongs to, so the target lands in a column past the end of the fork while React Flow still draws the line from a handle on the card — the connectors cross, the step appears under the wrong branch, and no predicate leads to it, so it can never run.
Two mechanisms, in order:
renameOutlets— index-map the edges when the outlet count is unchanged. Only then: mapping[a, b, default]onto[a, default]by position would rewire b's steps onto default and silently merge two paths.reconcileOutlets— for the ambiguous cases it refuses, move an edge that names a missing outlet onto the first free real outlet, in outlet order, so the repair is deterministic. It only ever touches an edge that was already unroutable.
Run the repair in an effect keyed on a shape signature that excludes positions, so dragging a card never triggers it:
export function graphSignature(nodes: Node[], edges: Edge[]): string { /* id:type:config | id:src>tgt@handle */ }
Removal goes through one question
const onBeforeDelete = React.useCallback(async ({ nodes: doomed }) => {
const first = doomed[0];
if (!first) return true;
requestDelete(first.id); // the builder decides; the dialog asks
return false; // React Flow never removes it itself
}, [requestDelete]);
The keyboard delete and the inspector's Remove button must reach the same
question — what happens to the steps under one that is going is not something
a keypress can answer. Deleting a lone connector is left alone: that one really
is just the edge. See destructive-action-confirmations.
Framing and chrome
- Fit with pixel padding, not a fraction. A fraction is a share of the
content, so a wide fork asks for proportionally more margin the wider it gets
and zooms itself into the distance. Cap
maxZoombelow 1 so a short flow does not fill the viewport with nowhere to build into. Pad the bottom deeper — the last card's plus and picker hang below it. - Pan to a landed or selected step only when it is off-screen, and pan rather than zoom. Branch columns put a new step half a viewport sideways from the plus you pressed; clicking a card you can already see must not move the canvas under the cursor.
- Replace
ControlsandMiniMapwith your own 44px foot: zoom, Fit, Format, Preview path, and a legend of step counts by family — which is the thing worth reading at a glance and the thing neither built-in says.
Performance rules that are not optional
React Flow hands back a fresh nodes array on every frame of a drag, and
per-node data is compared by reference.
- Hoist node handlers into one stable object that takes a node id, and pass
it through
data. Built per node they are three fresh closures per card per frame, and every card re-renders whenever any one node moves. - Read the live graph in click handlers through refs, not dependencies, so the builder API keeps one identity for the editor's lifetime.
- Memoise anything derived from shape on a signature that excludes positions,
and
useDeferredValuethe graph before running a whole-flow simulation for the field picker. - Never put UI wiring on a node where
serializeGraphcould see it. Inject chrome in the render memo instead — the saved graph stays the model.