ReactFlow for Family Tree Visualization
A family tree is a graph. The data is a set of nodes — people — connected by typed edges: parent-child, spouse, sibling. The visualization problem is how to lay that graph out intelligibly at different scales, from a three-generation view to a deep lineage spanning many generations and hundreds of individuals. ReactFlow provides the rendering and interaction infrastructure. The interesting decisions are in the custom node types, the Dagre layout algorithm, the edge styling for relationship types, and the performance strategies that become necessary as trees grow.
This post covers the implementation decisions we made, the trade-offs involved, and the places where ReactFlow’s abstractions both helped and constrained what we could build.
Why ReactFlow
The alternative we considered seriously was D3. D3’s tree layout algorithms are mature, well-documented, and have been used for genealogy visualization for years. The problem is that D3 requires managing the DOM directly, which conflicts with React’s reconciliation model if you want React components for the interactive elements. Hybrid approaches work but are awkward to maintain.
ReactFlow keeps everything in React’s component model. Nodes and edges are plain JavaScript objects in state. The library handles rendering (HTML nodes, SVG edges), pan and zoom, and interaction events. Custom node types are just React components. This makes it straightforward to embed rich interactive content in each node — photo placeholders, expandable detail sections, action buttons — without stepping outside React’s model.
The trade-off is that ReactFlow is more opinionated than D3. The node and edge data model has a specific shape, the layout is handled externally (ReactFlow does not include a layout algorithm), and performance at very large node counts requires more explicit management than a pure D3 approach. For a project in the few-hundred-node range, these trade-offs are favorable.
Custom Node Types: Person Cards
ReactFlow’s custom node system is one of its strongest features. A custom node type is a React component that receives the node’s data as props and renders whatever you want. The only requirement is that it renders the Handle components at the connection points where edges attach.
The PersonNode component renders a card with the person’s name, birth year, death year (if applicable), and a photo placeholder that shows the person’s initials when no photo is available. The card has a subtle border that changes color based on generation depth — a visual cue that helps orient the viewer in a deep tree. Living people and deceased people use distinct visual treatments.
One design decision that significantly improved clarity: the node uses separate handles for each edge type: parent-child handles on the top and bottom, and spouse handles on the left and right sides. Parent-child edges connect bottom-to-top, so the tree flows vertically with a consistent direction. Spouse edges connect side-to-side at the same generation level. This separation of edge types by handle position makes the directionality of relationships immediately readable.
The node component also handles the “collapsed” state for deep subtrees. When a subtree is collapsed, the node renders a badge showing the number of hidden descendants. Clicking the badge expands the subtree. This interaction model is essential for making large trees usable — a flat view of four hundred nodes is unreadable, but a view where most of the tree is collapsed and you expand branches on demand is navigable.
Dagre Layout for Hierarchical Positioning
ReactFlow does not calculate node positions. You provide positions as part of the node data, and the library renders them where you tell it to. For a family tree, calculating those positions manually would require reimplementing a hierarchical layout algorithm. The standard choice is Dagre, a directed acyclic graph layout library that produces clean hierarchical layouts with minimal edge crossings.
The Dagre integration sits outside the ReactFlow render cycle. When the node or edge data changes, a layout function runs synchronously, assigns positions to each node, and returns the positioned node array that ReactFlow renders. The function takes the raw nodes and edges, constructs a Dagre graph, runs the layout, and reads back the computed x and y values.
import dagre from '@dagrejs/dagre';
function layoutTree(nodes, edges, nodeWidth, nodeHeight) {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: 'TB', ranksep: 80, nodesep: 40 });
nodes.forEach(node => {
g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach(edge => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
return nodes.map(node => {
const { x, y } = g.node(node.id);
return { ...node, position: { x: x - nodeWidth / 2, y: y - nodeHeight / 2 } };
});
}
Dagre’s rankdir parameter controls the layout direction. “TB” (top-to-bottom) is the natural choice for a family tree where time flows downward across generations. ranksep controls vertical spacing between generations; nodesep controls horizontal spacing between siblings. These values need tuning for the specific node sizes you are using.
The limitation of Dagre for family trees is that it treats the graph as a DAG. Spouse edges — which connect two nodes at the same rank and are undirected — are not natural DAG edges. The workaround is to run the Dagre layout using only parent-child edges to establish generation ranks and horizontal positions, then draw spouse edges separately as straight lines at the same y coordinate. The simplified function above lays out every edge it receives, so filter the edge list to parent-child edges before calling it. Dagre alone also does not guarantee that spouses end up next to each other or on the same rank — a spouse with no recorded parents and no children in the visible tree has no parent-child edge at all — so spouse positions need an adjustment pass after layout to get the intended result: spouses side by side at the same level with a horizontal edge between them.
Edge Styling for Relationship Types
ReactFlow supports custom edge components in the same way as custom node components. Each relationship type — parent-child, spouse, adoptive, step — uses a distinct edge style. Parent-child edges are solid lines. Spouse edges are thin horizontal connectors. Adoptive relationships use a dashed line. Step relationships use a dotted line.
The edge component receives the source and target node positions as props and renders an SVG path. For straight edges this is trivial. For curved edges between nodes at different positions, ReactFlow provides a set of path helper functions (getStraightPath, getBezierPath, getSmoothStepPath) that generate the correct SVG path string for the given endpoint coordinates.
One interaction the custom edge components enable is an edge label that appears on hover, showing the relationship description in full. This is useful for complex family structures where the visual style alone may be ambiguous — hovering an edge between two nodes shows “Adoptive parent — 1987” or “Married 1975, divorced 1989.” The label renders as a foreignObject in the SVG, which allows using standard HTML and CSS for the tooltip style.
Performance with Large Trees
ReactFlow handles moderate-sized trees — up to a few hundred nodes — without specific optimization. Beyond that, a few strategies become necessary.
ReactFlow can render only the nodes and edges within the current viewport. This is off by default and is enabled with the onlyRenderVisibleElements prop; once enabled, it handles most of the performance concern for large trees, provided the node components themselves are not expensive to render. A PersonNode that triggers external data fetches or heavy computations on every render will cause jank regardless of viewport culling. Keep node components pure and fast.
The second strategy is memoizing the nodes and edges arrays. Because ReactFlow re-renders whenever the node or edge arrays change by reference, passing newly constructed arrays on every parent render causes unnecessary work. Wrapping the layout computation in useMemo with a dependency on the raw data — not on derived state — ensures the expensive layout computation and the associated re-render only happen when the underlying data actually changes.
The third strategy is the collapse/expand interaction described in the node type section. A tree with 400 nodes that displays only 40 at any given time performs much like a 40-node tree. The collapse state is managed in React state at the visualization level, not in the underlying data model. Collapsing a subtree filters it from the nodes and edges arrays passed to ReactFlow; expanding it adds it back. The Dagre layout re-runs with the filtered set.
Interaction Design Considerations
Beyond the layout and rendering mechanics, the interaction model for a family tree visualization requires careful thought. Pan and zoom are provided by ReactFlow out of the box, but the initial view and zoom level matter significantly for usability. Calling fitView() after the layout renders ensures the tree is visible and appropriately scaled on load.
Navigation between individuals — clicking a person to center them in the viewport, following edges to adjacent generations — requires the ReactFlow instance methods setCenter() and fitBounds(). These are accessed via the useReactFlow() hook and should be called with smooth animation enabled to give the user a sense of spatial continuity.
Search and filter — finding a specific person by name and jumping to their position in the tree — is a common requirement for any non-trivial family tree. The implementation makes the matched node pulse after centering the viewport on it. This is done by adding a CSS animation class to the matching node for a short duration, then removing it. The ReactFlow updateNode method handles this without triggering a full tree re-layout.
Related Insights
- Closure Tables for Relationship Calculation — the database layer that makes “how are these two people related?” answerable in a single query, complementing the ReactFlow visualization layer
- Widget Flags: Why ISO Country Codes Beat Locale-Dependent Names — another project where stable identifiers for entities (country codes vs. names) prevented a class of subtle bugs