← All articles

From SQL to screen: rendering .NET tree data in a Vue SPA

The follow-up to our recursive-CTE deep dive: rebuilding server-side hierarchies client-side with a TypeScript TreeList, a useTree composable, and drag-and-drop re-parenting.
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. findParents returns an array. For the edge-list shape our /categories/offspring endpoint 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 selected values;
  • 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 from roots);
  • move(node, parent?) — detaches a node and re-parents it; a falsy parent makes 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.TreeListToTreeView() (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.