I built a diagram editor for genograms. If you have not run into the term: a genogram is a diagram of a family that records more than descent. It uses standardized shapes for people and a set of line conventions for relationships, including emotional ones like closeness, distance, and conflict.
The obvious library choice was React Flow (@xyflow/react). It is a good fit and I would choose it again. But it is worth being precise about what it gives you, because the gap between "React Flow demo" and "editor a professional can use" is where all the work lives.
What you get out of the box
- A canvas with pan, zoom, and viewport controls
- A node/edge data model with React components for each
- Selection, dragging, and connection handles
- A store you can subscribe to
That is a lot. It is also entirely structural. React Flow knows nothing about your domain.
What it does not know
Here is the list I had to build on top:
Node rendering rules. A genogram person symbol is not a rounded rectangle. It encodes gender (square or circle), and inside it may sit a marker for a health status such as active, in recovery, or suspected. Custom node components are the answer, rendered as SVG inside the node wrapper.
Edge routing rules. Family relationships need their own geometry. Partners connect with a horizontal line. Children descend from a midpoint on that line. That midpoint changes when partners are added or removed, which means edge geometry is a function of the surrounding nodes, not of the two endpoints.
// conceptually: edges are derived, not stored per pair
const familyEdges = buildFamilyEdges(people, partnerships);
Relationship lines beyond descent. A genogram distinguishes emotional relationships from structural ones, and the conventions differ: a triple line for close, a zigzag for conflict, a dotted line for distant. Each is a different edge component with different path geometry and a different legend entry.
That means the domain model has to classify edges before the renderer ever sees them. React Flow will happily draw a custom edge; deciding which edge type a pair of people should have is your problem.
The legend. Any editor that introduces non-obvious notation needs a legend on the canvas. Users cannot be expected to remember that a dashed line means distant. This is a domain requirement with no library support at all.
Persistence is a separate design problem
Two storage modes ended up coexisting:
Local projects use IndexedDB, which immediately raises a concurrency question: what happens when two saves overlap? A debounced autosave, a manual save, and an export path can all write to the same record in the same second.
The fix is to serialize the writes rather than hoping they do not collide:
let operationQueue: Promise<unknown> = Promise.resolve();
function enqueue<T>(operation: () => Promise<T>): Promise<T> {
const next = operationQueue.then(operation, operation);
operationQueue = next.then(() => undefined, () => undefined);
return next;
}
Note the error handling: operationQueue is advanced with handlers that swallow both fulfillment and rejection, so one failed write does not poison every subsequent operation in the chain. The caller still gets the real rejection via next.
The autosave itself is debounced rather than interval-driven — 700 ms after the last change, not every 700 ms. Debouncing means an idle editor writes nothing and a burst of edits produces one write instead of thirty.
Cloud projects are a different code path with the same UI. Keeping the load/save surface identical (loadProject / saveProject regardless of backing store) is what prevents the editor component from filling up with if (isCloud) branches.
Validate what you read, always
Both storage paths return data that arrived from outside the component: IndexedDB, a file the user picked, or an API response. None of them are guaranteed to match the current schema.
export function loadLocalProject(projectKey = LOCAL_PROJECT_KEY): Promise<Project | null> {
return enqueue(async () => {
const project = await (await getDatabase()).get(LOCAL_PROJECT_STORE_NAME, projectKey);
return project ? validateProject(project) : null;
});
}
Two decisions are embedded in that short function:
- Validate on read, not on write. A project saved by an older version of the app will still be in IndexedDB after you ship a schema change. Validating on read lets you migrate or reject it at the only moment you can still do something sensible.
-
Return
nullrather than throwing. A missing or invalid project is an expected state — it means "start fresh" — not an exception. Reserve throwing for corrupt-file cases where the user needs to know their data could not be recovered.
Export is its own feature
Exporting a diagram to an image is not canvas.toDataURL(). Your nodes are DOM elements, styled with CSS, possibly with fonts and inline SVG.
html-to-image handles the DOM-to-PNG conversion, but there is still real work:
- computing the bounding box across all nodes, not just the visible viewport
- deciding a scale factor so the output is legible rather than 1:1 screen pixels
- generating a filename
- deciding what to do when a node falls outside the computed bounds
If your diagram has a "fit to view" interaction, the export path usually wants the same geometry logic. Sharing that function between the "fit" button and the export saves you from two subtly different definitions of "the whole diagram."
The parts that took the longest
Ranked by how long they actually took, not by how interesting they are:
- Edge geometry that responds to node movement. Correct, legible lines between related nodes.
- Round-trip fidelity between storage, the editor model, and the file format. Every added field is a migration concern.
- Legend and notation consistency. Domain accuracy, not engineering.
- Undo/redo and selection semantics. Most of it is state design, not rendering. Snapshotting the project on each mutation and capping the history at 100 entries is the easy half; making sure a drag does not push 60 entries into the history is the hard half.
- Actually wiring React Flow up. The least of the problems.
That ordering is the point. React Flow was the fastest part of the project. What took time was deciding what the diagram means and keeping that meaning consistent across the canvas, the store, the export, and the saved file.
If you are starting a similar editor
- Use React Flow. It is not the bottleneck.
- Write your domain model first, then derive edges from it rather than storing them independently.
- Treat every read from storage or the network as untrusted input and validate on read.
- Serialize writes if more than one code path can save.
- Plan the legend and notation before the visual polish.
- Share geometry helpers between "fit to view" and "export".
This is the architecture behind MyGenogramMaker, a browser-based genogram editor with standard family symbols, relationship lines, and editable templates. Projects can be kept locally in the browser or in a cloud project, and the editor runs without requiring an account to start.
Disclosure: MyGenogramMaker is my own project. It is a diagramming tool — not a diagnostic, clinical, or compliance service.
Top comments (0)