Server / lazy (Mode B)
By default the engine computes the whole aggregated tree once and ships it, so every expand/collapse is client-side with zero round-trips (Mode A — see virtualization). When the tree is too large to ship at once, switch to lazy Mode B: the grid starts at the root level and fetches a node’s children only when you expand it. The engine still does all the math — each expand re-materialises just that node’s children — and results are cached by stable node id, so a re-expand is instant with zero network.
from dash import Input, Output, callbackfrom dash_tensor_grid import TensorGridfrom dash_tensor_grid.engine import GridDataEngine
engine = GridDataEngine(sales) # a source too large to ship as one treedims = ["region", "country", "city"]agg = {"revenue": "sum", "cost": "sum"}
# Mode B: ship NO tree up front — only the column defs. `data_mode="lazy"`.grid = TensorGrid( id="grid", data_mode="lazy", column_defs=engine.make_column_defs(dims, agg),)
# Expanding a node pushes the full `expanded_state` map; re-materialise only those# nodes' children (lazy=True). Frontend owns expanded_state, Python owns row_data.@callback(Output("grid", "row_data"), Input("grid", "expanded_state"))def lazy_tree(expanded): return engine.get_tree_payload(dims, agg, expanded=expanded or {}, lazy=True)import { useLazyTree, type RowNode } from '@tensorgrid/react';import { renderGridHtml } from '@tensorgrid/core';
// `fetchChildren(id)` returns ONE node's immediate children (from your server) by stable id.function LazyGrid({ roots, fetchChildren }: { roots: RowNode[]; fetchChildren: (id: string) => Promise<RowNode[]> }) { const lt = useLazyTree({ initialRoots: roots, fetchChildren }); const html = renderGridHtml(lt.tree, ['region', 'country', 'city'], [{ accessorKey: 'revenue', header: 'Revenue', formatStr: '$0,0' }], { expanded: lt.expanded }); return ( <div dangerouslySetInnerHTML={{ __html: html }} onClick={(e) => { const row = (e.target as HTMLElement).closest('tr[data-id]'); if (row?.hasAttribute('aria-expanded')) lt.toggle(row.getAttribute('data-id')!); }} /> );}<script setup lang="ts">import { computed } from 'vue';import { useLazyTree, type RowNode } from '@tensorgrid/vue';import { renderGridHtml } from '@tensorgrid/core';
const props = defineProps<{ roots: RowNode[]; fetchChildren: (id: string) => Promise<RowNode[]> }>();const lt = useLazyTree({ initialRoots: props.roots, fetchChildren: props.fetchChildren });
const html = computed(() => renderGridHtml(lt.tree.value, ['region', 'country', 'city'], [{ accessorKey: 'revenue', header: 'Revenue', formatStr: '$0,0' }], { expanded: lt.expanded.value }));
const onClick = (e: MouseEvent) => { const row = (e.target as HTMLElement).closest('tr[data-id]'); if (row?.hasAttribute('aria-expanded')) lt.toggle(row.getAttribute('data-id')!);};</script>
<template> <div v-html="html" @click="onClick" /></template>import { cacheFromTree, mergeChildren, renderGridHtml } from '@tensorgrid/core';
// `roots` = the Mode-B payload's root level; `fetchChildren(id)` hits your server.const dims = ['region', 'country', 'city'];const measures = [{ accessorKey: 'revenue', header: 'Revenue', formatStr: '$0,0' }];const cache = cacheFromTree(roots, new Map()); // seed from any inline childrenconst expanded = {};
const render = () => { let tree = roots; for (const [id, kids] of cache) if (expanded[id]) tree = mergeChildren(tree, id, kids); el.innerHTML = renderGridHtml(tree, dims, measures, { expanded });};
el.addEventListener('click', async (e) => { const row = e.target.closest('tr[data-id]'); if (!row?.hasAttribute('aria-expanded')) return; const id = row.getAttribute('data-id'); expanded[id] = !expanded[id]; if (expanded[id] && !cache.has(id)) cache.set(id, await fetchChildren(id)); // fetch once render();});render();- The frontend does zero math. Every node’s children are computed by the Polars engine (Dash)
or the parity-verified
@tensorgrid/coreport; the client only splices the returned nodes into the display tree (mergeChildren). - Cached by stable node id. The first expand fetches; a re-expand of an already-fetched node is instant with zero network. A data/shape change (edit / regroup / filter) invalidates the cache but keeps the expanded state — the frontend owns view state, so it survives a recompute.
- Auto-escalate. Let the engine choose the mode from a cheap node-count estimate:
build_grid(data, {"rows": dims, "measures": agg, "auto_escalate": True, "node_count_threshold": 50000})setsdata_modefor you — Mode A (full tree, client-side) for a small tree, Mode B (lazy) past the threshold. The estimate is a conservative upper bound, so it can only over-escalate a borderline tree, never silently ship a huge one. - For a large client-side tree that still fits in one payload, use virtualization; for a huge flat source, use the infinite row model.