PackagesGraph
Migrating from @dagrejs/graphlib
A guide for developers moving an existing @dagrejs/graphlib (often plus dagre) codebase to @statelyai/graph.
A guide for developers moving an existing @dagrejs/graphlib (often plus dagre) codebase to @statelyai/graph.
Why migrate
- Plain JSON data model. A graph is a plain object (
{ nodes: [], edges: [] }), not a class instance. It serializes withJSON.stringify, diffs structurally, and crosses worker/process boundaries with no hydration step. graphlib requiresgraphlib.json.write/readround-trips. - Performance. Standalone algorithm functions over indexed plain arrays are competitive with or faster than graphlib across traversal, shortest-path, and component workloads. See benchmarks.
- Maintained, broader algorithms. Everything in
graphlib.alghas an equivalent here, plus centrality, communities, max-flow, isomorphism, dominators, and more. - Dagre still works.
getDagreLayoutfrom@statelyai/graph/layout/dagreruns dagre for you and returns a new positioned graph.
Concept mapping
| graphlib | @statelyai/graph |
|---|---|
new Graph({ directed: true }) | createGraph({ mode: 'directed' }) (the default) |
new Graph({ directed: false }) | createGraph({ mode: 'undirected' }) — plus per-edge mode overrides for mixed graphs |
new Graph({ multigraph: true }) | Native — every edge has an explicit id, so parallel edges just work |
new Graph({ compound: true }) | Native — set parentId on nodes |
g.setNode('a', label) | addNode(graph, { id: 'a', label: 'A', data: {...} }) |
g.setEdge('a', 'b', label, name) | addEdge(graph, { id: 'e1', sourceId: 'a', targetId: 'b', data: {...} }) |
g.removeNode(v) / g.removeEdge(v, w) | deleteNode(graph, id) / deleteEdge(graph, id) |
g.setNode(v, newLabel) (upsert) | updateNode(graph, id, { ... }) / updateEdge(graph, id, { ... }) |
| Node/edge label (arbitrary value) | data (arbitrary JSON); label here is a display string; numeric weight goes in weight |
g.node(v) / g.hasNode(v) | getNode(graph, id) / hasNode(graph, id) |
g.edge(v, w) | getEdge(graph, edgeId) or getEdgesBetween(graph, sourceId, targetId) |
g.nodes() / g.edges() | graph.nodes / graph.edges — plain arrays, just read them |
g.successors(v) / g.predecessors(v) / g.neighbors(v) | getSuccessors / getPredecessors / getNeighbors |
g.inEdges(v) / g.outEdges(v) / g.nodeEdges(v) | getInEdges / getOutEdges / getEdgesOf |
g.setParent(v, p) / g.parent(v) / g.children(v) | parentId on the node config / getParent(graph, id) / getChildren(graph, id) |
g.sources() / g.sinks() | getSources(graph) / getSinks(graph) |
g.setGraph(label) / g.graph() | data on the graph config / graph.data |
g.filterNodes(fn) | getSubgraph(graph, nodeIds) |
graphlib.json.write(g) / read | Not needed — the graph already is JSON |
graphlib.alg.*
| graphlib | @statelyai/graph |
|---|---|
alg.dijkstra(g, source, weightFn) | getShortestPaths(graph, { from }) (all reachable targets) or getShortestPath(graph, { from, to }) (single pair, bidirectional Dijkstra) |
alg.dijkstraAll / alg.floydWarshall | getAllPairsShortestPaths(graph, { algorithm: 'dijkstra' | 'floyd-warshall' | 'bellman-ford' }) |
alg.tarjan(g) | getStronglyConnectedComponents(graph) |
alg.topsort(g) | getTopologicalSort(graph) — returns null on cycles instead of throwing CycleException |
alg.components(g) | getConnectedComponents(graph) |
alg.preorder(g, vs) / alg.postorder(g, vs) | getPreorder(graph, { from }) / getPostorder(graph, { from }) (also getPreorders/getPostorders and lazy genPreorders/genPostorders) |
alg.dfs(g, vs, order) | genDFS(graph, startId) / genBFS(graph, startId) — lazy generators yielding nodes |
alg.isAcyclic(g) | isAcyclic(graph) |
alg.findCycles(g) | getCycles(graph) (or lazy genCycles(graph)) |
alg.prim(g, weightFn) | getMinimumSpanningTree(graph, { algorithm: 'prim' | 'kruskal', getWeight }) |
Worked example
Before: graphlib + dagre
import * as graphlib from '@dagrejs/graphlib';
import dagre from '@dagrejs/dagre';
const g = new graphlib.Graph({ directed: true, multigraph: true });
g.setGraph({ rankdir: 'LR' });
g.setDefaultEdgeLabel(() => ({}));
g.setNode('a', { label: 'Start', width: 100, height: 40 });
g.setNode('b', { label: 'Middle', width: 100, height: 40 });
g.setNode('c', { label: 'End', width: 100, height: 40 });
g.setEdge('a', 'b', { weight: 1 }, 'e1');
g.setEdge('b', 'c', { weight: 2 }, 'e2');
const distances = graphlib.alg.dijkstra(g, 'a', (e) => g.edge(e).weight);
// distances['c'].distance === 3
dagre.layout(g); // mutates g; positions on g.node('a').x / .y (centers)After: @statelyai/graph
import { createGraph, getShortestPath } from '@statelyai/graph';
import { getDagreLayout } from '@statelyai/graph/layout/dagre';
const graph = createGraph({
nodes: [
{ id: 'a', label: 'Start', width: 100, height: 40 },
{ id: 'b', label: 'Middle', width: 100, height: 40 },
{ id: 'c', label: 'End', width: 100, height: 40 },
],
edges: [
{ id: 'e1', sourceId: 'a', targetId: 'b', weight: 1 },
{ id: 'e2', sourceId: 'b', targetId: 'c', weight: 2 },
],
});
const path = getShortestPath(graph, { from: 'a', to: 'c' });
// path.source.id === 'a'
// path.steps.map((s) => s.node.id) → ['b', 'c']
const laidOut = getDagreLayout(graph, { direction: 'right' });
// Pure: returns a new VisualGraph; `graph` is untouched.
// laidOut.nodes[0].x / .y are top-left coordinates (dagre reports centers;
// the adapter converts). Edge polylines land on edge.points.getDagreLayout requires @dagrejs/dagre as an optional peer dependency, sets up the multigraph/compound graphlib graph internally (including parentId → setParent), and accepts raw dagre options via graphOptions for anything not covered by direction/spacing.
Gotchas
- Mutations throw; graphlib upserts silently.
g.setNodecreates-or-updates andg.removeNodeis a silent no-op. Here,addNode/addEdgethrow if the id already exists (or an edge endpoint is missing), anddeleteNode/deleteEdge/updateNode/updateEdgethrow if the id doesn't exist. Pickadd*vsupdate*deliberately during migration. - Edge ids are explicit. graphlib identifies edges by
(v, w, name); here every edge has a requiredid, which is also what makes multigraphs free. Lookup by endpoints isgetEdgesBetween(graph, sourceId, targetId)(returns an array — there may be parallel edges). - Label vs data vs weight. graphlib's "label" is one arbitrary value per node/edge. Here it splits three ways:
label(display string),data(arbitrary JSON payload, defaults tonull),weight(number on edges). - Default weight is 1, not your label.
graphlib.alg.dijkstradefaults every edge to weight 1 unless you pass aweightFnreading your label. Here weighted algorithms default toedge.weight ?? 1; passgetWeight: (edge) => ...to read fromdatainstead. - Directedness is a mode, and it can be mixed. Instead of a constructor-time
directedboolean, the graph hasmode: 'directed' | 'undirected' | 'bidirectional'and individual edges may override it — something graphlib cannot express. topsortdoesn't throw.getTopologicalSortreturnsnullfor cyclic graphs instead of raisingCycleException. Check the return value.- Layout is pure.
dagre.layout(g)mutates your graph in place;getDagreLayout(graph)returns a newVisualGraphand leaves the input alone. Node positions are top-left based, not centers. - Compound is always on. No
compound: trueflag — setparentIdon any node.addNodevalidates that the parent exists. - In-place field mutation needs
invalidateIndex. Indexes rebuild automatically when you replace arrays or use the mutation API, but if you reach in and edit a node/edge field directly, callinvalidateIndex(graph).
Coverage gaps
Every graphlib.alg.* function has a direct equivalent (see table above). What has no one-line counterpart:
setDefaultNodeLabel/setDefaultEdgeLabel— no default-data factories; supplydataper entity.graphlib.json.write/readproduce graphlib's specific JSON shape; if you have stored graphs in that format, you'll need a small one-time conversion toGraphConfig(nodes/edges arrays withid/sourceId/targetId).