DEV Community

Ayush
Ayush

Posted on

Building Smarter Tree UIs: A Deep Dive into Legoblock's Headless Renderer

View on Legoblock npm package

In the world of modern web applications, hierarchical data is everywhere. File systems, organizational charts, navigation menus, comment threads, and nested filters all share a common challenge: rendering tree structures efficiently while maintaining clean, maintainable code. Enter Legoblock, a headless recursive renderer that elegantly solves this problem with a fresh approach to nested data visualization.

What is Legoblock?

Legoblock (@legoblock/ui) is a lightweight React library that handles the complex parts of tree rendering—recursion and immutable state updates—while giving you complete control over the visual presentation. Unlike monolithic component libraries that lock you into specific designs, Legoblock embraces the "headless UI" philosophy: it manages the logic, you control the markup.

At its core, Legoblock takes your tree data and a "recurring node" component, then recursively clones that component for each node in your tree. Along the way, it injects powerful helpers like addNode, deleteNode, updateNode, and updateAllChildrenNode, turning what could be dozens of lines of boilerplate into a single elegant declaration.

Why Choose a Headless Approach?

Traditional tree component libraries often come with built-in styling, icons, and interaction patterns. While convenient for prototyping, they become constraints when your design requirements diverge from the library's opinions. You end up fighting against the library's CSS, working around its component structure, or worse—ejecting entirely and losing all the benefits.

Legoblock solves this by being radically unopinionated about presentation. It doesn't render a single <div> or apply a single CSS class without your explicit instruction. Your recurring node component is just a regular React component—you can use Tailwind, CSS modules, styled-components, or inline styles. You control the DOM structure completely.

This separation of concerns means you get the best of both worlds: robust tree logic handled by the library, and complete creative freedom for your UI.

Core Concepts Explained

The Recurring Node Pattern

The heart of Legoblock is the "recurring node" concept. You create a single component that represents one node in your tree, and Legoblock handles the recursion:

function TreeNode({ node, children, deleteNode, addNode }) {
  return (
    <div className="node">
      <div className="node-content">
        <span>{node.name}</span>
        <button onClick={deleteNode}>Delete</button>
        <button onClick={() => addNode({ name: 'New Item', type: 'file' })}>
          Add Child
        </button>
      </div>
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice the {children} prop? That's where the rendered subtree appears. Legoblock recursively processes your tree data, cloning your component at each level and passing in the appropriate node data and the rendered children for that node.

Path-Based Updates

One of Legoblock's clever design choices is its use of "access paths." Each node knows its position in the tree as an array of indices like [0, 2, 1], meaning "first child, third grandchild, second great-grandchild." When you call deleteNode() or updateNode(), Legoblock uses this path to navigate directly to the target location in O(depth) time, rather than searching the entire tree.

This approach combines the simplicity of recursive rendering with the performance of direct addressing.

Immutable Updates Made Easy

React developers know the importance of immutable state updates, especially with nested data. Manually cloning deep tree structures while updating specific nodes is tedious and error-prone. Legoblock handles this complexity for you.

When any modification occurs, Legoblock:

  1. Creates a single structuredClone of your tree
  2. Applies the change to the cloned structure
  3. Calls your updatedRecurringData callback once with the new tree

This ensures React's reconciliation works correctly while keeping your update logic simple.

Real-World Use Cases

File System Explorer

Building a file browser? Legoblock makes it straightforward:

type FileNode = {
  name: string;
  type: 'file' | 'folder';
  size?: number;
  children?: FileNode[];
};

function FileItem({ node, children, addNode, deleteNode }) {
  const isFolder = node?.type === 'folder';

  return (
    <div style={{ paddingLeft: 16 }}>
      <div className="file-row">
        <span className="icon">{isFolder ? '📁' : '📄'}</span>
        <span className="name">{node?.name}</span>
        {isFolder && (
          <>
            <button onClick={() => addNode({ name: 'new-file.txt', type: 'file' })}>
              New File
            </button>
            <button onClick={() => addNode({ name: 'new-folder', type: 'folder', children: [] })}>
              New Folder
            </button>
          </>
        )}
        <button onClick={deleteNode}>Delete</button>
      </div>
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Cascading Filters

Need hierarchical filters where selecting a parent selects all children? The updateAllChildrenNode helper makes this trivial:

function FilterNode({ node, children, updateAllChildrenNode }) {
  const handleToggle = (checked) => {
    updateAllChildrenNode(
      { selected: checked },
      { includeSelf: true }
    );
  };

  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={!!node?.selected}
          onChange={(e) => handleToggle(e.target.checked)}
        />
        {node?.label}
      </label>
      <div className="children">{children}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The updateAllChildrenNode function traverses the entire subtree starting from the current node and applies your update to every descendant. You can even provide predicate functions to filter which nodes get updated, or limit the depth of recursion.

Organization Chart

Render an interactive org chart with expand/collapse functionality:

type Employee = {
  name: string;
  role: string;
  expanded: boolean;
  children?: Employee[];
};

function EmployeeNode({ node, children, updateNode }) {
  const toggleExpanded = () => {
    updateNode({ ...node, expanded: !node?.expanded });
  };

  return (
    <div className="employee-card">
      <h3>{node?.name}</h3>
      <p>{node?.role}</p>
      {node?.children?.length > 0 && (
        <button onClick={toggleExpanded}>
          {node?.expanded ? 'Collapse' : 'Expand'}
        </button>
      )}
      {node?.expanded && children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Advanced Features

Flexible Insertion Points

The addNode helper supports three insertion strategies:

  • 'child' (default): Add as a child of the current node
  • 'before': Insert as a sibling before the current node
  • 'after': Insert as a sibling after the current node
<button onClick={() => addNode(newItem, 'after')}>
  Add Sibling Below
</button>
Enter fullscreen mode Exit fullscreen mode

This flexibility is crucial for building intuitive tree editors where users need fine-grained control over item placement.

Bulk Updates with Predicates

The updateAllChildrenNode function is remarkably powerful. Beyond simple cascading updates, you can:

Update only nodes matching a condition:

updateAllChildrenNode(
  { priority: 'high' },
  { 
    predicate: (node) => node.type === 'task' && !node.completed 
  }
);
Enter fullscreen mode Exit fullscreen mode

Limit update depth:

updateAllChildrenNode(
  { visible: false },
  { depth: 2 } // Only update current node and two levels down
);
Enter fullscreen mode Exit fullscreen mode

Transform with a function:

updateAllChildrenNode(
  (node) => ({ 
    ...node, 
    timestamp: Date.now() 
  })
);
Enter fullscreen mode Exit fullscreen mode

Custom Children Field

Not all tree structures use children as the property name. Legoblock supports customization:

type MenuItem = {
  label: string;
  items?: MenuItem[]; // Using 'items' instead of 'children'
};

updateAllChildrenNode(
  { expanded: true },
  { childrenField: 'items' }
);
Enter fullscreen mode Exit fullscreen mode

Performance Characteristics

Legoblock's design prioritizes both developer experience and runtime performance:

Targeted Updates: Using access paths, updates target specific nodes in O(depth) time rather than searching the entire tree.

Single Clone: Each operation performs exactly one structuredClone, avoiding unnecessary copies and maintaining predictable performance.

Localized Work: Operations like delete and add only modify the relevant portion of the tree—a parent's children array, not the entire structure.

Subtree Operations: When updating multiple nodes (like cascading selection), the algorithm only traverses the affected subtree, not the whole tree.

React-Friendly: By maintaining immutability and emitting updates once per operation, Legoblock enables efficient React reconciliation.

For a tree with 1,000 nodes at depth 5, updating a single node is roughly O(5) + O(siblings) rather than O(1000). This difference becomes dramatic as trees grow larger.

Getting Started

Installation is straightforward via npm or yarn:

npm install @legoblock/ui
# or
yarn add @legoblock/ui
Enter fullscreen mode Exit fullscreen mode

A minimal example looks like this:

import { NestedStructure } from '@legoblock/ui';
import { useState } from 'react';

function App() {
  const [tree, setTree] = useState([
    {
      name: 'Root',
      children: [
        { name: 'Child 1' },
        { name: 'Child 2' }
      ]
    }
  ]);

  return (
    <NestedStructure
      recurringNode={<TreeNode />}
      recurringData={tree}
      updatedRecurringData={setTree}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it. Three props, and you have a fully functional recursive tree renderer.

When to Use Legoblock

Legoblock shines when you need:

  • Full visual control: Your design system is unique, and off-the-shelf components won't cut it
  • Complex tree interactions: Beyond simple rendering—you need adding, deleting, moving, and bulk updates
  • Type safety: TypeScript support is first-class, with proper generics for your node types
  • Performance at scale: Trees with hundreds or thousands of nodes that need efficient updates
  • Minimal bundle size: No CSS, no icons, no unnecessary dependencies

Conversely, if you need a quick prototype and a default tree UI is fine, a heavier component library might be faster to set up initially.

Comparison with Alternatives

Traditional tree libraries like react-treeview or rc-tree provide complete components with styling and interactions included. They're great for rapid prototyping but limit customization.

Lower-level solutions require you to hand-code recursion and immutable updates yourself—full control but significant boilerplate.

Legoblock occupies the sweet spot: it handles the complex algorithmic work while leaving presentation entirely to you. You get the power of a custom solution with the convenience of a library.

Best Practices

Keep Node Components Pure: Your recurring node should be a simple presentational component. Keep business logic in parent components or custom hooks.

Leverage Access Paths: For advanced features like drag-and-drop, the accessPath prop gives you the exact location of each node.

Use TypeScript: Define your node type and pass it to RecurringNodeProps<YourNodeType> for full type safety.

Optimize Rendering: If your tree is very large, consider memoizing your recurring node component with React.memo().

Controlled State: Always maintain your tree as React state. Legoblock is fully controlled—it never maintains internal state.

Conclusion

Legoblock represents a thoughtful approach to a common problem in web development. By embracing headless architecture, it provides powerful tree manipulation capabilities without imposing design constraints. Whether you're building a file manager, a comment system, a menu builder, or any other hierarchical interface, Legoblock handles the hard parts so you can focus on creating great user experiences.

The library's emphasis on immutability, performance, and developer ergonomics makes it a solid choice for projects where tree structures are central to the user interface. With its minimal API surface and maximum flexibility, Legoblock proves that sometimes the best libraries are the ones that do less—but do it exceptionally well.

Ready to try it out? Check out the live playground on CodeSandbox or install it in your project today. Your next tree UI just got a lot simpler to build.

Top comments (0)