Tree State in a Visual Builder

How we modeled tree state, selection, and multi level nesting in a visual app builder without the recursive React render storm.

The first prototype of the visual app builder I worked on at a major creator-economy platform held the whole creator app as one nested React state object. A Section had Rows. Rows had Columns. Columns had Blocks. Blocks could be Group blocks with their own children. We shipped it to a handful of internal users, opened the demo on a real branded app with about 40 components, and watched a single drag of one button across the canvas take two full seconds to land. Every drop mutated a deep object. Every render walked the whole tree. The CPU profile looked like a brick wall.

That was the day I stopped writing nested trees in React state.

This is the story of how we got the builder from “feels broken” to something creators could actually ship branded apps with. The shape that worked: a flat normalized map, parent ids only, the tree derived on read. Selection is its own store. Drag and drop never touches component state.

Flat beats nested. Always.

The instinct when you have a tree is to model it as a tree. Nodes own children. Children own grandchildren. It reads beautifully. It runs terribly.

The problem is React. A nested state object means every mutation produces a new reference for every ancestor up to the root. A drop two levels deep invalidates the root. Memoization breaks. Reconciliation walks the whole canvas. With ~40 components I could see the slowness. At the scale our top creators were actually shipping at, the editor would have been unusable.

Flat with parentId is the only model I’d ship for this now. Lookups by id are O(1). A reparent touches exactly two nodes. Children are derived, not stored, so there is no “two sources of truth” bug where a node lists a child the child does not believe in.

import { create } from 'zustand';

export type NodeKind =
  | 'section'
  | 'row'
  | 'column'
  | 'block'
  | 'group';

export interface BuilderNode {
  id: string;
  kind: NodeKind;
  parentId: string | null;
  order: number;
  props: Record<string, unknown>;
}

interface BuilderState {
  nodes: Record<string, BuilderNode>;
  rootId: string;
  upsert: (node: BuilderNode) => void;
  remove: (id: string) => void;
  reparent: (id: string, parentId: string, order: number) => void;
}

export const useBuilder = create<BuilderState>((set) => ({
  nodes: {},
  rootId: 'root',

  upsert: (node) =>
    set((s) => ({ nodes: { ...s.nodes, [node.id]: node } })),

  remove: (id) =>
    set((s) => {
      const next = { ...s.nodes };
      const drop = (target: string) => {
        for (const n of Object.values(next)) {
          if (n.parentId === target) drop(n.id);
        }
        delete next[target];
      };
      drop(id);
      return { nodes: next };
    }),

  reparent: (id, parentId, order) =>
    set((s) => {
      const node = s.nodes[id];
      if (!node) return s;
      if (parentId === id) return s;
      let cursor: string | null = parentId;
      while (cursor) {
        if (cursor === id) return s;
        cursor = s.nodes[cursor]?.parentId ?? null;
      }
      return {
        nodes: {
          ...s.nodes,
          [id]: { ...node, parentId, order },
        },
      };
    }),
}));

Two things worth pointing at. The cycle check in reparent is non negotiable. Without it, a careless drag of a Group onto one of its own descendants produces a state that crashes the renderer the next tick. Ask me how I know. The other thing is order as a plain number. Fractional indexing (insert between 1 and 2 as 1.5) keeps reorders O(1). We rebalance on a schedule, not on every drop.

Children are derived. The selector below is the only place the tree shape exists, and it lives outside the store.

import { useMemo } from 'react';
import { useBuilder, type BuilderNode } from './store';

export function useChildren(parentId: string): BuilderNode[] {
  const nodes = useBuilder((s) => s.nodes);
  return useMemo(() => {
    const out: BuilderNode[] = [];
    for (const n of Object.values(nodes)) {
      if (n.parentId === parentId) out.push(n);
    }
    out.sort((a, b) => a.order - b.order);
    return out;
  }, [nodes, parentId]);
}

This selector returns a new array every time nodes changes, which sounds bad. It isn’t, because each BuilderNode reference is stable unless that specific node was mutated. The child component subscribes by id and re renders only when its own slot of the map changes.

Rendering without the recursion storm

A recursive TreeNode is the obvious shape. It is also where most builders melt. The fix is to subscribe per node, not per tree.

import { memo } from 'react';
import { useBuilder } from './store';
import { useChildren } from './selectors';

interface Props { id: string }

export const TreeNode = memo(function TreeNode({ id }: Props) {
  const node = useBuilder((s) => s.nodes[id]);
  const children = useChildren(id);

  if (!node) return null;

  return (
    <div data-node-id={node.id} data-kind={node.kind}>
      <NodeChrome node={node} />
      {children.map((c) => (
        <TreeNode key={c.id} id={c.id} />
      ))}
    </div>
  );
});

Each TreeNode reads its own slice. memo keeps it stable when nothing in its slice has changed. Dragging Block A inside Column 3 does not rerender Section 1 across the canvas. The win is visible in DevTools. With 200 nodes we went from rendering the entire tree on every prop edit to rendering exactly the node being edited.

One detail that matters more than it should. key={c.id} must be the id, never the index. Indexes change on reorder, React reuses DOM with stale state, and the focus ring jumps off the block the creator was just editing. That is the kind of bug that does not throw, but a creator filing a support ticket about it is worth ten that did.

Selection is its own world

Selection does not belong in the node map. Selecting a row should not invalidate the row’s props. We split it.

import { create } from 'zustand';
import { useBuilder } from './store';

interface SelectionState {
  ids: Set<string>;
  anchorId: string | null;
  toggle: (id: string, mode: 'single' | 'additive' | 'range') => void;
  clear: () => void;
}

export const useSelection = create<SelectionState>((set, get) => ({
  ids: new Set(),
  anchorId: null,

  clear: () => set({ ids: new Set(), anchorId: null }),

  toggle: (id, mode) => {
    const { ids, anchorId } = get();
    const nodes = useBuilder.getState().nodes;

    if (mode === 'single') {
      set({ ids: new Set([id]), anchorId: id });
      return;
    }

    if (mode === 'additive') {
      const next = new Set(ids);
      next.has(id) ? next.delete(id) : next.add(id);
      set({ ids: next, anchorId: id });
      return;
    }

    if (!anchorId || nodes[anchorId]?.parentId !== nodes[id]?.parentId) {
      set({ ids: new Set([id]), anchorId: id });
      return;
    }

    const parentId = nodes[id].parentId;
    const siblings = Object.values(nodes)
      .filter((n) => n.parentId === parentId)
      .sort((a, b) => a.order - b.order);

    const a = siblings.findIndex((n) => n.id === anchorId);
    const b = siblings.findIndex((n) => n.id === id);
    const [lo, hi] = a < b ? [a, b] : [b, a];

    const next = new Set(siblings.slice(lo, hi + 1).map((n) => n.id));
    set({ ids: next, anchorId: id });
  },
}));

Range select respects the parent. A shift click never crosses container boundaries, because creators do not think “I want these four blocks plus that random column from a different section.” They think “this row of cards.” Hierarchy is the constraint that makes selection feel right.

The selection bleed story

A Tuesday afternoon, a few weeks after we cut over to the new selection store. A creator on the design partner program filed a ticket. Range selecting four cards in a row also selected a button in the section above. Repro on my machine. The first fix I tried was to bake parent matching into the sort. Wrong move. It hid the symptom on flat layouts and broke nested groups, because Group blocks have their own children and the sort no longer described a real order. I rolled it back inside an hour.

The real fix was the parentId guard you can see in the code above, plus a small unit test suite for range select that asserts “selection never leaves the anchor’s container.” Cost: about two hours of the design partner’s editor feeling off, plus my own mid afternoon. Lesson, again, that selection is geometry on top of structure and the structure has to be respected literally, not approximately.

Drag and drop stays out of state

The biggest mistake I see in builder code is wiring dnd-kit (or react-dnd) directly into the node store. Every hover updates state. The canvas thrashes. Drop preview lags behind the cursor.

We use dnd-kit’s DragOverlay for the moving thing and a separate ephemeral state for the hovered drop zone. The node map only changes on drop. Once.

import {
  DndContext,
  DragOverlay,
  type DragEndEvent,
} from '@dnd-kit/core';
import { useBuilder } from './store';

export function Canvas({ children }: { children: React.ReactNode }) {
  const reparent = useBuilder((s) => s.reparent);

  const onDragEnd = (e: DragEndEvent) => {
    const dropId = e.over?.id;
    if (!dropId || typeof dropId !== 'string') return;

    const draggedId = String(e.active.id);
    const order = Number(e.over?.data.current?.order ?? 0);

    reparent(draggedId, dropId, order);
  };

  return (
    <DndContext onDragEnd={onDragEnd}>
      {children}
      <DragOverlay>{/* lightweight ghost */}</DragOverlay>
    </DndContext>
  );
}

That’s it. One write per drop. The visual feedback during the drag is local component state inside the drop zones, which is cheap because each zone owns its own boolean.

A note from the canvas at scale

The other anchor for me here is a community and talent product I CTO on the side, where we built a Circle style community surface with rich threaded posts. Different shape, same lesson. We started with nested replies as nested state. Two hundred replies in, the editor was crawling. Flattening the reply map with parentPostId plus deriving threads on read fixed it in an afternoon. The pattern travels. If you have a tree and you have a React renderer, the tree lives flat.

Takeaways

  • Flat normalized map with parentId. Tree is derived, never stored.
  • Use fractional order for O(1) reorders. Rebalance on a schedule.
  • Subscribe per node. memo plus an id key buys you the canvas.
  • Keep selection in its own store. Range select stays within a parent.
  • Drag and drop writes to state once, on drop. Hover state is local.
  • Cycle check every reparent. Without it, a Group dropped into itself takes the editor down.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.