@memoir/tree
Memoir Labs

Integration

Build app features around @memoir/tree without moving your data model.

@memoir/tree is a renderer. It does not ship a database, editor, router, auth system, analytics layer, or form flow.

Your app owns the graph. Tree renders the graph.

Responsibilities

Keep these in your app:

  • Person records and profile fields.
  • Relationship creation, validation, and persistence.
  • Permissions for viewing or editing family or org data.
  • Selection state, side panels, forms, routes, and analytics.
  • Card markup and app-specific actions.

Tree handles measured cards, layout, SVG edges, pan/scroll viewport state, subject-relative family labels, spouse-aware family grouping, org hierarchy, and accessibility props.

Use graph mode for production family data:

import { useState } from "react";
import { FamilyTree, type FamilyGraph } from "@memoir/tree";
import "@memoir/tree/styles.css";

type Person = {
  id: string;
  name: string;
};

export function FamilyPanel({ initialGraph }: { initialGraph: FamilyGraph<Person> }) {
  const [graph, setGraph] = useState(initialGraph);
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <FamilyTree
      graph={graph}
      selected={selected}
      onPersonClick={(_person, personId) => setSelected(personId)}
    />
  );
}

Fetch from your API, normalize into FamilyGraph, render FamilyTree, then save the changed graph back through your app. Tree never writes to your data.

Add A Family Member

Adding a person means updating people plus the relationship facts that connect that person to the visible family.

function addChildToParents(parentA: string, parentB: string, groupId: string) {
  const childId = crypto.randomUUID();

  setGraph((current) => ({
    ...current,
    people: {
      ...current.people,
      [childId]: { id: childId, name: "New child" },
    },
    parentChildLinks: [
      ...current.parentChildLinks,
      {
        id: `${parentA}-${childId}`,
        groupId,
        parentId: parentA,
        childId,
        relation: "biological",
      },
      {
        id: `${parentB}-${childId}`,
        groupId,
        parentId: parentB,
        childId,
        relation: "biological",
      },
    ],
  }));
}

That is enough for Tree to re-render the child under the correct parent group. Use one parent-child link for a single-parent child, two links for a two-parent child, and more only when your domain actually models more parent links.

Add A Partner And Child

When a child belongs to a new union, create the partner, the group, and the child links together:

function addPartnerAndChild(subjectId: string, partnerName: string, childName: string) {
  const partnerId = crypto.randomUUID();
  const childId = crypto.randomUUID();
  const groupId = `${subjectId}-${partnerId}`;

  setGraph((current) => ({
    ...current,
    people: {
      ...current.people,
      [partnerId]: { id: partnerId, name: partnerName },
      [childId]: { id: childId, name: childName },
    },
    partnershipGroups: [
      ...current.partnershipGroups,
      {
        id: groupId,
        partners: [subjectId, partnerId],
        relation: "coparent",
        order: current.partnershipGroups.length + 1,
      },
    ],
    parentChildLinks: [
      ...current.parentChildLinks,
      { id: `${subjectId}-${childId}`, groupId, parentId: subjectId, childId },
      { id: `${partnerId}-${childId}`, groupId, parentId: partnerId, childId },
    ],
  }));
}

The important part is the shared groupId. That is what makes the child come out of the union instead of one arbitrary parent node.

Stable IDs And Diffs

For clean diffs and predictable re-renders:

  • Keep people keyed by stable person IDs.
  • Keep each partnershipGroups[].id stable.
  • Keep each parentChildLinks[].id stable after creation.
  • Use the same groupId for parent-child links that belong to the same union.
  • Use order when several unions or child groups need a stable visual order.

Reordering equivalent parentChildLinks does not change rendered relationship IDs. Changing IDs, group IDs, relation kinds, or order values is treated as a real model change.

Custom Cards

Custom cards are regular React components:

import type { FamilyCardProps } from "@memoir/tree";

export function ProfileCard({ focused, person, relation, ...rootProps }: FamilyCardProps<Person>) {
  return (
    <article {...rootProps}>
      <strong>{person.name}</strong>
      <small>{relation.label}</small>
    </article>
  );
}

Spread rootProps so the tree can attach accessibility props, handlers, and stable data attributes.

Use cardProps for app-owned card inputs:

<FamilyTree
  graph={graph}
  card={ProfileCard}
  cardProps={(person) => ({
    href: `/people/${person.id}`,
    canEdit: canEditFamily,
  })}
/>

Editing Hooks

Use tree callbacks to open your app UI:

<FamilyTree
  graph={graph}
  selected={selectedPersonId}
  onPersonClick={(_person, personId) => setSelectedPersonId(personId)}
  onAddRelationship={(_person, personId) => openAddRelativeDialog(personId)}
  readOnly={!canEditFamily}
/>

readOnly prevents add-relationship actions from being passed into cards. It does not block selection.

Viewport Interaction

interactionMode defaults to "pan". Users can drag the canvas and non-interactive card surfaces with mouse, touch, or pen. Buttons, links, inputs, selects, textareas, contenteditable elements, and anything marked with data-tree-drag-ignore keep their own pointer behavior.

Use "pan-page-scroll" when the tree should drag with mouse or horizontal touch gestures, while vertical touch gestures scroll the page. Use "scroll" for normal browser scrollbars or "none" for a static viewport:

<FamilyTree graph={graph} interactionMode="scroll" />

Org Data Flow

Org charts can use simple mode or graph mode. Use graph mode when reporting links have database IDs:

import { OrgChart, type OrgChartGraph } from "@memoir/tree";

export function TeamPanel({ graph }: { graph: OrgChartGraph<Person> }) {
  return <OrgChart graph={graph} />;
}

Adding a report is the same pattern:

setGraph((current) => ({
  ...current,
  people: {
    ...current.people,
    [reportId]: { id: reportId, name: "New report" },
  },
  reportingLinks: [
    ...current.reportingLinks,
    { id: `${managerId}-${reportId}`, managerId, reportId },
  ],
}));

Checks

When integrating:

  • Confirm cards render the expected names and labels.
  • Confirm click and keyboard activation work when onPersonClick is provided.
  • Confirm nested buttons inside cards behave the way your app expects.
  • Confirm dragging works from both the canvas and non-interactive card surfaces when interactionMode="pan".
  • Use limits for large or blended families.
  • Check multiple-union examples visually when your data has several partners or child groups.
  • Keep the stylesheet import near your app root if many pages use the tree.

Relationship Troubleshooting

When family lines do not connect where you expect:

  • Prefer graph mode for blended families.
  • Check that both parents in a two-parent child group use the same groupId.
  • Keep groupId stable across edits.
  • Remember that parentage does not create a spouse or partner bar. Add a partnershipGroups record only when that relationship should be visible.
  • A rendered tree shows each person once. If someone appears duplicated, check whether a direct role is also being modeled as a lateral relative in your source data.
  • Check relation kinds. Biological plus step/adoptive links in the same group intentionally render as distinct edge kinds.
  • Use order on partnership groups and parent-child links when several unions should render in a specific left-to-right order.
  • Confirm every referenced person ID exists in people; missing cards are skipped by layout and routing.
  • Keep custom card roots spreading the provided props.

For the exact prop surface, see API.

On this page