JavaScript is required

Leaf Expand Buttons with Lazy Child Loading

A relation-graph demo that shows how leaf nodes can display built-in expand buttons before they have children. Expanding those placeholders triggers a one-shot simulated async load, appends new nodes and lines with the graph instance API, and reruns tree layout so the new branch settles into place.

Lazy Child Loading from Leaf Expand Buttons

What This Example Builds

This example builds a full-height left-to-right tree that mixes one static branch with one highlighted lazy-loading branch. The canvas starts with a white b subtree that is already populated and a gold c subtree whose leaf placeholders c1, c2, and c3 already show expand buttons even though they do not have children yet.

Users can click the built-in expand button on those placeholder leaves, see a Loading... overlay for one second, and then watch three generated child nodes appear under the expanded node. The main point is not generic expand or collapse behavior, but the technique for turning an empty leaf into a one-shot lazy-load trigger and then rerunning layout so the new branch settles into place.

How the Data Is Organized

The data is assembled inline inside initializeGraph() as one RGJsonData object with rootId: 'a', a flat nodes array, and a flat lines array. The b side of the tree is fully defined up front, while the c side contains three placeholder leaves that carry extra metadata in node.data.

There are two lightweight preprocessing decisions before any runtime loading happens. First, nodes c1, c2, and c3 are marked with expandHolderPosition: 'right' and expanded: false so they can expose relation-graph’s built-in expand holder without preloaded descendants. Second, each placeholder stores isNeedLoadDataFromRemoteServer and childrenLoaded flags in data, which lets the expand handler decide whether it should fetch more graph content or ignore repeat requests.

The runtime loader does not rebuild the whole tree. Instead, loadChildNodesFromRemoteServer(...) generates a small RGJsonData fragment from the expanded node id and returns three child nodes plus three connecting lines after a setTimeout(...). In a real project, the same shape could represent remote org-chart branches, permission-scoped folder trees, product category drill-down, or dependency subgraphs returned by an API.

How relation-graph Is Used

index.tsx wraps the example in RGProvider, and MyGraph.tsx gets the live graph instance through RGHooks.useGraphInstance(). The graph options configure a tree layout that grows from the left, sets treeNodeGapH: 100, keeps expand holders on the right, uses rectangular nodes, uses RGLineShape.StandardOrthogonal, and anchors lines with RGJunctionPoint.lr. That combination produces a compact orthogonal hierarchy where new descendants extend horizontally from the expanded placeholder.

The instance API drives both initialization and mutation. On mount, the component calls setJsonData(...), then moveToCenter() and zoomToFit() so the starting tree is framed immediately. During lazy loading, the onNodeExpand handler calls loading('Loading...'), appends new data with addNodes(...) and addLines(...), runs doLayout() after the async callback completes, and finishes with clearLoading(). The example also enables reLayoutWhenExpandedOrCollapsed: true, so expand-state changes remain layout-aware even though the async branch growth is handled explicitly through doLayout().

There are no custom node slots, line slots, canvas slots, viewport slots, or editing tools in this example. The visual customization is intentionally small: the SCSS file keeps mostly empty wrapper selectors and changes .rg-node-expand-button to background-color: var(--rg-node-color), which makes the built-in expand control inherit each node’s color. That is why the gold lazy-loading branch also gets gold expand buttons without slot-based rendering.

Key Interactions

  • Clicking the built-in expand button on c1, c2, or c3 starts a one-shot lazy-loading flow instead of only opening preloaded descendants.
  • A Loading... overlay appears during the simulated remote delay, so the user gets immediate feedback that the branch is waiting for data.
  • When the async callback returns, the graph appends new nodes and lines under the expanded placeholder and recalculates layout so the new branch lands in a stable position.
  • Repeating the same expand request does not append duplicate children, because each eligible placeholder flips childrenLoaded to true before the callback runs.

Key Code Fragments

This fragment shows the core placeholder technique: leaf nodes can expose expand holders before they have children because the node metadata marks them as expandable and not yet loaded.

{ id: 'c', text: 'c-dynamic-childs', color: '#dcb106' },
// By setting expandHolderPosition property for node, nodes without children can also show an [Expand/Collapse] button
{ id: 'c1', text: 'c1-childs-from-remote', color: '#dcb106', expandHolderPosition: 'right', expanded: false, data: { isNeedLoadDataFromRemoteServer: true, childrenLoaded: false } },
{ id: 'c2', text: 'c2-childs-from-remote', color: '#dcb106', expandHolderPosition: 'right', expanded: false, data: { isNeedLoadDataFromRemoteServer: true, childrenLoaded: false } },
{ id: 'c3', text: 'c3-childs-from-remote', color: '#dcb106', expandHolderPosition: 'right', expanded: false, data: { isNeedLoadDataFromRemoteServer: true, childrenLoaded: false } }

This fragment shows that expansion is intercepted through onNodeExpand and turned into guarded async graph mutation.

const onNodeExpand = (nodeObject: RGNode, $event: RGUserEvent) => {
    if (!nodeObject.data?.isNeedLoadDataFromRemoteServer || nodeObject.data?.childrenLoaded) {
        return;
    }

    graphInstance.loading('Loading...');
    nodeObject.data.childrenLoaded = true;
    loadChildNodesFromRemoteServer(nodeObject, async(newData) => {
        // Instance obtained via Hook directly calls appendJsonData
        graphInstance.addNodes(newData.nodes);
        graphInstance.addLines(newData.lines);
        await graphInstance.doLayout();
        graphInstance.clearLoading();
    });
};

This fragment shows that the new branch is generated as a small RGJsonData payload instead of by rebuilding the original tree.

const _new_json_data: RGJsonData = {
    nodes: [
        { id: nodeObject.id + '-child-1', text: nodeObject.id + '-dynamic child node 1' },
        { id: nodeObject.id + '-child-2', text: nodeObject.id + '-dynamic child node 2' },
        { id: nodeObject.id + '-child-3', text: nodeObject.id + '-dynamic child node 3' }
    ],
    lines: [
        { id: nodeObject.id + '-dl1', from: nodeObject.id, to: nodeObject.id + '-child-1' },
        { id: nodeObject.id + '-dl2', from: nodeObject.id, to: nodeObject.id + '-child-2' },
        { id: nodeObject.id + '-dl3', from: nodeObject.id, to: nodeObject.id + '-child-3' }
    ]
};

This fragment shows the layout and graph defaults that make the lazy-loaded branch read as a left-to-right orthogonal tree.

const graphOptions: RGOptions = {
    layout: {
        layoutName: 'tree',
        from: 'left',
        treeNodeGapH: 100
    },
    reLayoutWhenExpandedOrCollapsed: true,
    defaultExpandHolderPosition: 'right',
    defaultNodeShape: RGNodeShape.rect,
    defaultLineShape: RGLineShape.StandardOrthogonal,
    defaultJunctionPoint: RGJunctionPoint.lr,
    defaultNodeColor: '#ffffff',
    defaultPolyLineRadius: 5,
};

This fragment shows that the expand button is restyled through CSS inheritance rather than through a custom slot or separate component.

.rg-node-peel {
    .rg-node {
        .rg-node-text {
        }
    }
    .rg-node-expand-button {
        background-color: var(--rg-node-color);
    }
}

What Makes This Example Distinct

The comparison data places this example near expand-forever, investment, expand-gradually, and tree-data, but it occupies a narrower niche than each of them. Compared with expand-forever, it emphasizes initial leaf placeholders with expand buttons before children exist and avoids recursive repeated loading, slot-based spinner rendering, and extra control UI. Compared with investment, it uses the same lazy-load trigger pattern without business-card node slots, line-percentage labeling, or level-specific layout logic.

The other useful contrast is with examples that only reveal already loaded data. expand-gradually focuses on opening preloaded descendants, and tree-data focuses on loading a hierarchy from nested data up front. This example instead starts from flat nodes and lines, appends brand-new fragments with addNodes(...) and addLines(...), and then explicitly calls doLayout() after async completion.

What stands out most is the combination of placeholder-leaf expansion, one-time load guards, explicit post-append relayout, and node-colored built-in expand buttons in a very small viewer with no slots. That makes it a strong starting point when the requirement is specifically “show an expand button on an empty leaf, fetch children on demand, and keep the rest of the graph close to default relation-graph styling.”

Where Else This Pattern Applies

This pattern transfers well to remote-backed hierarchy views where the full dataset is too large, too permission-sensitive, or too slow to preload. Typical examples include organization branches, account ownership trees, service dependency drill-down, category explorers, and folder trees where users only open a few branches at a time.

It also works as a base for richer on-demand graph products. A team could extend the same idea with real API calls, per-branch error states, deeper recursive loading by assigning the same metadata to newly appended nodes, or caching rules that decide whether a collapsed branch should reload or reuse previously fetched children.