When an app’s data is a set of things that point at each other, a node-and-edge graph is often the clearest way to show it. We needed exactly that for Generative Grammar Engine, which displays a generative grammar as a graph: each rule is a node, and a #reference# from one rule to another is an edge. The graph is rendered with React Flow and laid out with Dagre. Neither part is a novel algorithm. The work that took thought was the wiring between the data and those two libraries, so this is a write-up of the three pieces we ended up supplying ourselves, in case the same pieces come up when you add a graph view of your own.
React Flow draws nodes and edges and handles pan, zoom, and selection. It does not know what the nodes mean, where they should sit, or when to rebuild them. Those decisions are left to the app, and they are the same ones anyone wiring a graph into their own app will run into.
Deriving the graph from your data
Nodes come straight from the keys of the grammar object, one node per rule. Edges are less direct: every alternative of every rule is scanned for #reference# markers that name another rule, and one edge is emitted per distinct pair. A reference that points at a rule which does not exist is skipped here, and surfaced elsewhere as a warning rather than left to fail silently.
function buildEdges(grammar) {
const keys = new Set(Object.keys(grammar));
const seen = new Set();
const edges = [];
for (const [from, alternatives] of Object.entries(grammar)) {
for (const alt of alternatives) {
for (const ref of referencesIn(alt)) {
if (!keys.has(ref)) continue; // skip references with no rule
const id = `${from}->${ref}`;
if (seen.has(id)) continue; // one edge per pair
seen.add(id);
edges.push({ source: from, target: ref });
}
}
}
return edges;
}
Each node gets a custom type so it renders as an editable card rather than React Flow’s default box. The type is registered once, and React Flow uses it wherever a node declares it:
const nodeTypes = { grammarSymbol: GrammarSymbolNode };
// ...
<ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} />
Placing the nodes with Dagre
React Flow needs an x and y for every node and will not compute them itself, so something has to decide where each node goes. That job goes to Dagre, a library that lays out directed graphs into clean ranks. It gets the nodes and edges along with each node’s size, runs the layout, and the resulting coordinates are copied back onto the nodes. One mismatch to handle: Dagre reports the center of each node, while React Flow positions by the top-left corner, so half the width and height is subtracted.
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'TB', nodesep: 48, ranksep: 72 }); // top to bottom
nodes.forEach((n) => g.setNode(n.id, { width: W, height: heightOf(n) }));
edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g);
const placed = nodes.map((n) => {
const d = g.node(n.id);
return { ...n, position: { x: d.x - d.width / 2, y: d.y - d.height / 2 } };
});
Each node’s height is estimated from its content, a header plus one row per alternative, so Dagre reserves enough vertical space and the cards do not overlap once they land.
Not laying out on every keystroke
This is the piece we would reach for again in a similar app. Running a layout is not cheap, and the grammar changes on every keystroke, yet most edits leave the shape of the graph untouched. Typing inside an alternative changes a literal string; it adds no node and no edge. Re-running Dagre on an edit like that would shove nodes around while someone is still typing.
So the layout is keyed to a fingerprint of the structure alone: the rule names, how many alternatives each rule has, and the set of references inside each alternative. Editing a literal leaves that fingerprint unchanged, so the previous layout stays in place and the graph holds still:
const fingerprint = (grammar) =>
Object.keys(grammar).sort().map((k) =>
grammar[k].map((alt) => referencesIn(alt).sort().join(",")).join("|")
).join("\n");
// re-run Dagre only when the fingerprint changes; otherwise reuse positions
Edits also commit on two separate timers: a short one before pushing changes back to the engine, and a separate one for layout. Keeping them apart lets generation stay responsive while the graph stays put.
The rest is configuration
The remaining behavior is React Flow options. People edit the grammar inside the node cards, so dragging and connecting are turned off and the canvas is left for reading. fitView on init frames the whole graph, and when an edit adds a node the view recenters on it. A reference to a rule that does not exist becomes a visible warning rather than a silently missing edge.
<ReactFlow
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
nodesDraggable={false} nodesConnectable={false}
onInit={(flow) => flow.fitView({ padding: 0.2 })}
onlyRenderVisibleElements
>
<Background /> <Controls />
</ReactFlow>
The parts we had to write
React Flow and Dagre carry the rendering and the layout. What we had to write ourselves was the mapping from the data to nodes and edges, the height estimates that give the layout room, and the rule for when to recompute the layout instead of redoing it on every change. That last piece, the structural fingerprint, is what makes the graph feel stable rather than twitchy, and it is the part we would borrow first if we built something like this again.
Generative Grammar Engine is free at grammar.tinygods.dev, and the code is open source at github.com/tinygodsdev/metatracery.