← All articles

The API Sends a List, the Screen Needs a Tree

Expandable, draggable hierarchies in Vue

Show the flat list an API returns as a tree users can expand, select and drag to a new parent. No recursive component, and illegal drops are refused in the browser before they reach the server.
The API Sends a List, the Screen Needs a Tree

A category tree, an org chart, a folder structure: the API hands the browser a flat list of rows with parent ids, and the screen has to show a tree — indented, selectable and, on an admin page, draggable to a new parent.

Part 2 left the server returning that list with parents always before their children; this part picks the response up in the browser and turns it back into something a user can expand, select 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.

Universal or Regira? Rebuilding a tree from a flat list, folding it and re-parenting a node after a drag are the universal parts, and any tree helper can do them; useTree and useDragDrop are the Regira Modules layer that wires them into Vue.

The round trip: on the server, Regira.TreeList flattens the categories with ToTreeView, parents before children; in the browser, TreeList.init rebuilds the tree from rows in any order and feeds a depth-first v-for indented by level, the useTree computeds and useDragDrop. A legal drop saves the new parent id and the tree is rebuilt from fresh data, while a drop on the node itself or on one of its descendants is refused in the browser.

npm install @regira/modules@6.4.0
import { TreeList, TreeNode } 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.

Being an array with a level on every node is what makes an indented list a single v-for with padding-left driven by node.level, no recursive component — shown once the tree is built below.

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 worth pausing on:

  • Any order. init looks up each value's parents wherever they sit in the list, so the rows need no sorting; a parent that would close a cycle leaves the value as a root instead of looping.
  • Multi-parent capable. findParents returns an array, so the edge-list shape the /categories/offspring endpoint returns (rows of { parentId, childId, level, rootId }) builds with the rule the C# repository uses in part 2 — a row's parent is the row that ends where it starts.

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 every node when called without an argument.

getNodes matches by value equality (node.value === input), so hold on to the object references from the payload rather than reconstructing lookalikes.

Indented and foldable

The array holds the nodes in the order they were added, which puts every parent before its children but not every child under its parent: a Laptops row that arrives after Garden sits after it, not under Computers. So the list walks the roots depth-first, and that walk is also where folding lives — a collapsed node contributes no children:

import { ref } from "vue"

const collapsed = ref(new Set<number>())   // ids of folded nodes

const visible = (nodes: TreeNode<CategoryRow>[]): TreeNode<CategoryRow>[] =>
  nodes.flatMap((n) => [n, ...(collapsed.value.has(n.value.id) ? [] : visible(n.children))])

const toggle = (id: number) =>
  collapsed.value.has(id) ? collapsed.value.delete(id) : collapsed.value.add(id)
<li v-for="node in visible(tree.roots)" :key="node.value.id"
    :style="{ paddingLeft: `${node.level * 16}px` }"
    @click="toggle(node.value.id)">
  {{ node.value.title }}
</li>

A filter panel: useTree

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 what a filter panel needs: check one category and highlight (or auto-include) its 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) — because that is what nodes and values are matched on. 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 to a new parent: useDragDrop

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 handles the drag and decides whether a drop is legal; applying it is yours:

import { useDragDrop, type DragDropEmits } from "@regira/modules/vue/entities"

const emit = defineEmits<DragDropEmits<Category>>()
const { draggingNode, handleDrag, handleDragEnd, handleDrop } = useDragDrop({ emit })

On each row, set draggable="true" and wire @dragstart to handleDrag(node), @dragover.prevent (without it the browser never fires drop), @drop to handleDrop(node) and @dragend to handleDragEnd. Then listen for the move event: it fires with the { child, parent } nodes once a legal drop happens. The composable refuses the illegal cases before that: dropping a node onto itself, and dropping it onto one of its own descendants, which would orphan the subtree into a cycle. That is 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), then call tree.move(child, parent) or rebuild from the response.

move keeps the tree consistent: it re-derives level for the moved subtree and refuses a move under the node itself or one of its descendants. It does not change the node's place in the flat array, which is why the list above walks children rather than the array. After a successful save, rebuilding from the server's rows is still the simplest way to stay in step with the source of truth.

Where the pieces sit

The division of labor across the three parts, which 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 is structurally best at, and the contract between them is nothing more exotic than a flat list with parent ids — which is also what makes the pattern easy to test at every seam.

For the large worked example, Westeros, the Iron Throne, and a Model for Complex Relationships puts the same TreeList over houses, oaths and shifting alliances, with the maps to prove it.

Why not a recursive tree component? It is the usual answer, and it works. Here the tree is already a flat array with a level on every node, so indentation is a style binding, and the questions a tree screen asks — what sits above or below this node, is this drop legal — are answered on that same array by useTree and useDragDrop, one object that sees the whole tree.

Docs: @regira/modules on npm · github.com/Regira/Regira-Modules.

Built with

Regira.Entities

Nesting a self-referencing table into a tree and flattening it back is Regira.TreeList; the repository around it is Regira Entities. Both are on nuget.org.

What Regira Entities covers TreeList documentation Free tier & licensing