Docs
Tree operations
Working with Retree's pure ownership tree — Retree.parent, and the three explicit operations for relocating nodes — move, link, and clone.
Retree keeps a pure ownership tree: every node has exactly one structural parent (the root has none). This page covers the APIs that work with that structure directly — finding a parent, and the three explicit operations for when an existing node needs to be somewhere else.
One structural parent#
Assigning a node that already lives in the tree into a second place throws, rather than silently aliasing:
const task = projectA.tasks[0];
projectB.tasks.push(task);
// ❌ throws: "Retree cannot assign this node because it already has a
// structural parent. ... Fix: choose one explicit ownership operation:
// move it with Retree.move(node, destination, key), store a reactive
// pointer with Retree.link(node) or @link, ignore it via @ignore, or
// duplicate it with Retree.clone(node)."The error names your options, and each one is a different intent:
Retree.move transfers ownership,
Retree.link points without owning, and
Retree.clone copies. (The one exception:
referencing a node twice within the same parent is allowed — this happens
naturally while moving an item between indices of one array.)
Find a parent with Retree.parent#
Retree.parent(node) returns the node's structural parent, or null for a
root. Because every node knows where it lives, tree-local operations like
"delete yourself" don't need any wiring:
import { Retree } from "@retreejs/core";
import { v4 as uuid } from "uuid";
class Todo {
readonly id = uuid();
public text = "";
public checked = false;
delete() {
// The parent of a Todo is the Array<Todo> that owns it.
const parent = Retree.parent(this);
if (!Array.isArray(parent)) return;
const index = parent.findIndex((c) => this.id === c.id);
parent.splice(index, 1); // ✅ emits on the parent list
}
}Two details worth knowing:
Retree.parent(likeRetree.on,move,link, andclone) expects a managed node — calling it with a plain, unrooted object is an error.- Links are not structural parents: if
editor.selectedTaskis a@linkfield pointing atproject.tasks[0],Retree.parent(editor.selectedTask)still returnsproject.tasks.
Move a node with Retree.move#
Retree.move(node, destination, key?) transfers
ownership. Retree finds the current parent, removes the node from it safely,
and inserts it into the destination. It returns the latest
reproxy for the moved node.
const workspace = Retree.root({
todo: [{ title: "Docs" }],
done: [] as { title: string }[],
});
const moved = Retree.move(workspace.todo[0], workspace.done);
workspace.todo.length; // 0
workspace.done[0] === moved; // trueThe key parameter depends on the destination type:
- Arrays — optional numeric insertion index; omit it to append.
- Maps and plain objects — a key is required.
- Sets — the key is ignored.
Retree.move(task, projectB.tasks); // ✅ append to an array
Retree.move(task, projectB.tasks, 0); // ✅ insert at index 0
Retree.move(task, tasksById, task.id); // ✅ Map or object key
Retree.move(task, taskSet); // ✅ Sets take no keyDo not manually delete the node from its old parent first — move does
the removal itself, and stripping the parent metadata beforehand breaks it.
On a ReactiveNode,
this.moveTo(destination, key?) wraps Retree.move(this, ...):
class Task extends ReactiveNode {
public title = "";
get dependencies() {
return [];
}
public archive(archiveList: Task[]) {
this.moveTo(archiveList); // same as Retree.move(this, archiveList)
}
}Link a node with Retree.link#
Retree.link(node) creates a
RetreeLink — a small managed object whose
.current points at the target. The link can be stored anywhere in the tree
without reparenting the target:
const root = Retree.root({
tasks: [{ title: "Write docs" }],
selectedTask: null as null | ReturnType<typeof Retree.link>,
});
root.selectedTask = Retree.link(root.tasks[0]);
root.selectedTask.current.title = "Write better docs";
// ✅ emits where the task is structurally owned (root.tasks[0])
Retree.parent(root.selectedTask.current) === root.tasks; // still trueThe event semantics split cleanly in two: replacing .current emits for
the link node; mutating the target emits from the target's structural
location. Reads of .current return the target's latest reproxy.
On ReactiveNode classes, the @link decorator gives the same semantics to
a plain field — assignment emits on the owner without reparenting, and
re-assigning the same target is a no-op:
class EditorState extends ReactiveNode {
@link public selectedTask: Task | null = null;
get dependencies() {
return [];
}
}
const state = Retree.root(new EditorState());
state.selectedTask = root.tasks[0]; // ✅ emits on state, ❌ does not reparent
state.selectedTask = root.tasks[0]; // ❌ no emit: already points thereThe full @link treatment — including using linked nodes in dependencies
and @select — lives in View models. Note that @link
is a decorator; check Setup & decorators for
toolchain configuration.
Clone a node with Retree.clone#
Retree.clone(node) returns a detached copy of the node's current data. The
copy has no parent until you assign it into a tree, and after that the two
are fully independent:
const copy = Retree.clone(root.tasks[0]);
root.tasks.push(copy); // ✅ emits on root.tasks; copy is a new child
copy.title = "Copy"; // ✅ emits for the copy only
root.tasks[0].title = "Orig"; // ✅ emits for the original onlyWhich operation do I want#
- The node should live somewhere new — its old parent should no longer
own it:
Retree.move. Typical: drag-and-drop between lists, archiving, re-keying. - One place should point at a node owned elsewhere — selection state,
cross-references, "currently editing":
Retree.linkor@link. The target stays put; the pointer is what changes. - Two places need independent state that starts out identical:
Retree.clone. After the clone, edits don't propagate between them. - A field should reference something without participating in the tree at
all — caches, framework handles:
@ignore. But note it is not reactive — see the pitfall.
Removal and nodeRemoved#
When a node is detached from its parent — splice, the delete keyword,
replacement by assignment — Retree emits nodeRemoved on the removed node
(the callback takes no arguments), alongside the parent's own
nodeChanged/treeChanged:
Retree.on(board.cards[0], "nodeRemoved", () => console.log("card removed"));
board.cards.splice(0, 1); // ✅ nodeRemoved(card), ✅ treeChanged(board)Use it for teardown that belongs to the node itself — cancelling requests, disposing external resources — instead of watching the parent and diffing. Subscription mechanics are covered in Events & subscriptions.