Live Data Updates

A new dataset does not mean a new chart. graph.updateData(data) diffs the incoming tree against the live one and springs to it, so a reorg, a websocket tick or a filter change reads as continuous motion instead of a redraw.

updateData

const tree = new ApexTree(document.getElementById('chart'), { direction: 'top' })
const graph = tree.render(initialData)

socket.on('org:changed', (next) => graph.updateData(next))

The diff runs on node id:

NodeBehavior
In both datasetsKeeps its DOM wrapper and springs from where it is to where the new layout puts it
Only in the new dataEnters from its parent
Only in the old dataRetracts into its parent and unmounts

Because the wrappers survive, so does everything the browser hangs off them: keyboard focus, hover state and text selection. So does ApexTree's own per-node state, which means collapse state, selection, focus mode and expanded cards all carry across an update.

Use tree.render(data) only for the first render, since it also builds the toolbar and other chrome. updateData falls back to a full rebuild when enableAnimation is false or before the first render has settled, so the end state is identical either way.

Stable ids are the contract

The whole diff hinges on ids. If ids are regenerated per payload, every node looks new: the old tree exits, a fresh one enters, and you get the redraw you were trying to avoid.

Derive ids from something durable in your domain, not from array position:

// stable: survives reordering, renaming and re-parenting
{ id: `emp-${employee.employeeId}`, name: employee.fullName }

// unstable: changes the moment anything is inserted or sorted
{ id: `node-${index}`, name: employee.fullName }

A node that keeps its id can be renamed, re-parented and re-titled across an update and it will still travel rather than blink.

Content-only changes

Editing a label without changing the shape of the tree is also an update. The reconciler detects content-only changes and re-renders that node's card in place, without disturbing the layout:

graph.updateData({ ...data, name: 'Alex Rivera (interim)' })

Time travel over snapshots

Because surviving nodes interpolate, stepping through historical snapshots produces a readable animation of how a hierarchy changed. The reliable way to build the snapshots is to fold them out of an event log, so ids stay stable by construction:

// events: [{ q: 0, type: 'hire', id, name, title, parentId }, ...]
function foldTo(quarter) {
  const people = new Map()

  for (const e of events.filter((e) => e.q <= quarter)) {
    if (e.type === 'hire') {
      people.set(e.id, { id: e.id, name: e.name, title: e.title, parentId: e.parentId })
    } else if (e.type === 'leave') {
      people.delete(e.id)
    } else if (e.type === 'move') {
      const p = people.get(e.id)
      if (p) people.set(e.id, { ...p, parentId: e.parentId })
    } else if (e.type === 'title') {
      const p = people.get(e.id)
      if (p) people.set(e.id, { ...p, title: e.title })
    }
  }

  return buildNested([...people.values()])   // your own parentId → children step
}

const graph = tree.render(foldTo(0))

scrubber.oninput = (e) => graph.updateData(foldTo(Number(e.target.value)))

Folding forward from events rather than diffing two arbitrary trees is what keeps a re-parented node identifiable: the move event changes its parentId while its id stays put, so ApexTree animates it to its new manager instead of deleting and recreating it.

Pacing

updateData retargets a spring that may still be in flight, and velocity carries across the retarget. That makes rapid updates safe: dragging a scrubber fast produces continuous motion rather than a queue of restarts.

For an auto-playing timeline, leave enough time between frames for the motion to read. Match the interval to the spring rather than firing as fast as the data arrives, and consider motion.spring: 'gentle' for large structural jumps. See Motion and Animation.