From SQL to screen: rendering .NET tree data in a Vue SPA
In the previous article we taught the database to walk a category hierarchy: recursive CTE functions mapped into EF Core, composed inside query filters, and a tree endpoint that returns the flattened result depth-first — parents always before their children. This time we pick that response up in the browser and turn it back into something a user can expand, collapse and drag around.
The tools are the front-end counterparts of the .NET pieces: the framework-agnostic TreeList from @regira/modules (plain TypeScript — no Vue, no axios in that module), and a small useTree composable that wraps it in Vue reactivity.
npm install @regira/modules
import { TreeList, TreeNode, type IFindParents } from "@regira/modules/treelist"
A TreeList that is also an array
The design mirrors the .NET Regira.TreeList deliberately. TreeList<T> extends Array<TreeNode<T>>: it is a flat array of every node in the tree, plus a separate roots array for the top level. Each TreeNode<T> exposes value, parent, level (0 for roots) and children as read-only getters, and a node is iterable — for (const child of node) walks its direct children.
Being an array is more useful than it sounds. Rendering an indented list is a single v-for over the tree with padding-left driven by node.level — no recursive component needed:
<li v-for="node in tree" :key="node.value.id"
:style="{ paddingLeft: `${node.level * 16}px` }">
{{ node.value.title }}
</li>
One subtlety to know: TreeList overrides Symbol.species to Array, so tree.filter(...) and tree.map(...) return a plain Array<TreeNode<T>>, not a TreeList. That's intentional — derived collections are views, not trees.
Building the tree from a flat response
init(values, findParents) wires a flat list into a hierarchy. You hand it a callback that, given a value and the full candidate list, returns that value's parent(s):
type CategoryRow = { id: number; parentId: number | null; title: string }
const rows: CategoryRow[] = (await axios.get("/categories?pageSize=1000")).data.items
// pageSize capped by the server's MaxPageSize — pick one that covers the whole table
const tree = new TreeList<CategoryRow>().init(
rows,
(value, candidates) => candidates.filter((x) => x.id === value.parentId)
)
tree.roots // top-level categories
tree.getNodes() // every node, flat
Two properties of init are worth calling out:
- Ordering matters — one level of tolerance. A child may precede its root-level parent in the input (the parent is synthesized once and reused — no duplicate roots), but a payload shuffled across more levels than that will mis-attach nodes. In practice you never depend on that tolerance: the server's
ToTreeView()already sends parents before children, so assembly is a clean single pass — keep that contract and ordering is a non-issue. - Multi-parent capable.
findParentsreturns an array. For the edge-list shape our/categories/offspringendpoint returns (rows of{ parentId, childId, level, rootId }), the same rule as the C# repository applies — a row's parent is the row that ends where it starts:
const edges = (await axios.get("/categories/offspring?ids=5")).data.items
const tree = new TreeList<TreeItem>().init(
edges,
(edge, all) => all.filter((p) => p.childId === edge.parentId)
)
Navigation reads exactly like the back end: tree.getRoots(nodes?), tree.getAncestors(nodes?), tree.getOffspring(nodes?), tree.getNodes(valueOrValues?), tree.getValues(nodes?) — all defaulting to the whole tree when called without an argument. getRoots and getAncestors deduplicate their results; getOffspring and getValues don't (call them per node, or pass through a Set, when overlapping subtrees would double-count). On a single node: node.getRoot(), node.getAncestors(), node.getOffspring().
Note that getNodes matches by value equality (node.value === input), so hold on to the object references from the payload rather than reconstructing lookalikes.
useTree — the reactive wrapper
For Vue components, @regira/modules/vue/entities ships a useTree<T>() composable that keeps a TreeList in a ref and derives the interesting sets as computeds:
import { useTree } from "@regira/modules/vue/entities"
const { tree, nodes, ancestors, offspring, family, init } = useTree<Category>()
// values: the items you care about (e.g. current selection)
// data: the full flat list the tree is built from
init(selectedCategories, allCategories,
(v, all) => all.filter((x) => x.id === v.parentId))
nodes— the tree nodes for your selectedvalues;ancestors/offspring— everything above / below that selection;family— ancestors + selection + offspring, deduplicated.
That trio is precisely what a filter panel needs: check one category and highlight (or auto-include) its whole family. One constraint to know up front: useTree<T> requires T to expose $id — the uniform identifier every entity model in the Regira front-end stack carries (EntityBase-style models) — that's what nodes and values are matched on. A custom equals option changes how nodes are matched, but not the constraint itself — so a plain { id, parentId, title } row type won't compile; map it onto your entity models (or add $id) before handing it to the composable. The raw TreeList from the previous section has no such requirement.
Drag-and-drop re-parenting
Trees users can edit need two mutations, and TreeList provides both:
remove(node)— removes the node and all its descendants, from the flat array, from its parent's children (or fromroots);move(node, parent?)— detaches a node and re-parents it; a falsyparentmakes it a root again.
The useDragDrop composable turns those into UI affordances:
import { useDragDrop, type DragDropEmits } from "@regira/modules/vue/entities"
const emit = defineEmits<DragDropEmits<Category>>()
const { draggingNode, handleDrag, handleDragEnd, handleDrop } = useDragDrop({ emit })
Wire handleDrag to @dragstart, handleDrop to @drop on each row, and listen for the move event — it fires with { child, parent } once a legal drop happens. The composable already refuses the illegal cases: dropping a node onto itself, and dropping it onto one of its own descendants (which would orphan the subtree into a cycle). That's the client-side mirror of the server's TreeList.IsValidChild check — validate in both places, trust neither alone.
On move, persist the change through your API (in a Regira Entities back end: save the entity with its new parentId, or add/remove a row in the join table for multi-parent models), and reconcile on the response.
One gotcha, documented and worth repeating: level is not recomputed on move. A node's _level is set at construction, so after re-parenting, indentation driven by node.level can be stale. If your rendering depends on levels, rebuild the tree from the updated flat data after a successful save — which you'll often do anyway, since the server is the source of truth.
Where the pieces sit
It's worth being explicit about the division of labor across this two-article arc, because it generalizes beyond categories:
| Concern | Where | With what |
|---|---|---|
| Any-depth filtering (subtree conditions inside queries) | Database | Recursive CTE TVFs composed in EF Core |
| Tree shape for one API response | Server memory | Regira.TreeList → ToTreeView() (depth-first) |
| Tree interaction (expand, select, drag) | Browser | @regira/modules/treelist + useTree / useDragDrop |
| Write validation (no cycles) | Both | level cap + IsValidChild server-side; offspring check in handleDrop client-side |
Each layer does the part it's structurally best at, and the contract between them is nothing more exotic than a flat, ordered list — which is also what makes the pattern easy to test at every seam.
Module source and docs: @regira/modules on npm · github.com/Regira/Regira-Modules — the treelist module and the entities tree composable.