This is the second post in the Bombie series. The first post introduced what Bombie is: a drag-and-drop builder for Material-UI in React, with a live demo at bombie-three.vercel.app. This one is about how it's actually put together.
Bombie's whole design comes from a single decision early on: represent the UI as a JSON tree, and write two renderers against it — one for the builder canvas, one for the live preview. Every other feature is a consequence of that.
The data model
Every node in a Bombie layout has the same shape:
{
id: "<uuid>",
info: { tag: "Button", /* catalog metadata */ },
props: { variant: "contained", color: "primary", children: "Save" },
child: [ /* nested nodes, same shape */ ]
}
A whole layout is a tree of these. That's it.
-
id— stable identifier; used by the property editor to target a specific node and byreact-dndto know what's being moved. -
info— catalog metadata pulled from the element registry. Thetagfield (e.g."Button","Grid") is what the renderers switch on. -
props— the props that will eventually land on the real MUI component. This is what the property editor mutates. -
child— nested nodes. Container components (Box, Grid, Stack, Container, Card, etc.) use this; leaf components (Typography, Button, etc.) leave it empty.
Saving a layout is JSON.stringify(tree). Loading is JSON.parse(json). That's the whole persistence story.
Mutating the tree
Mutating a deeply nested immutable tree is the kind of thing that, done naively, spawns three helper files and a Stack Overflow tab. Bombie's lives in one file: src/Lib/Utils/json-handler.js. It's a collection of recursive walks that take the root tree and the target node's id, and return a new tree.
The pattern is the same for every operation:
function updateNode(node, targetId, transform) {
if (node.id === targetId) return transform(node);
if (!node.child?.length) return node;
return { ...node, child: node.child.map(c => updateNode(c, targetId, transform)) };
}
Everything specializes from there:
-
setProp(tree, id, key, value)→updateNodewith a transform that returns{ ...n, props: { ...n.props, [key]: value } }. -
appendChild(tree, parentId, newNode)→updateNodeonparentId, transform adds tochild. -
removeNode(tree, id)→ filterchildrecursively. -
move(tree, fromId, toParentId, index)→ remove then insert.
Because the tree is immutable, React's useState + useMemo works without any special diffing. The Provider that holds the tree (under Controller/ComponentGenerator/) hands it down through a context (bombie-context.js) and exposes these handlers to the canvas, the palette, and the property editor.
The other utility worth calling out is js-dom-controller.js. It does dot-path get/set on nested objects — useful when a prop like slotProps.input.startAdornment needs to be edited from the property dialog. Together, json-handler (for tree mutations) and js-dom-controller (for nested prop paths) cover most of the editing surface.
Two renderers, one tree
Here's the design call that pays off the most: Bombie has two renderers for the same tree.
Builder canvas (element-recursion.js)
The canvas renders every node with builder chrome: a selection outline, a wrench button (open property editor), a delete button, drop-zone overlays via react-dnd. Containers render their children recursively as drop targets, so you can nest a Stack inside a Grid inside a Box to whatever depth you want.
Two complications the canvas has to handle that a clean preview doesn't:
-
Drop targets need real space. An empty
Boxwould collapse to zero pixels, so the canvas-side container components apply a minimum size and a dashed outline when empty. -
Modals are inline. A
Dialogin the canvas renders inline (not as an actual modal) so you can edit the dialog's content tree. If it rendered as a real modal, you couldn't see the content unless you opened it — which is fine for previewing but useless for editing.
Iframe preview (render-preview.js)
The preview walks the same tree and emits clean MUI JSX. No outlines, no wrench buttons, no minimum heights. A Dialog becomes a real <Dialog> with a generated trigger button: click the trigger, the dialog opens, click any button inside, it closes. That's important because dialog UX is the kind of thing you actually want to feel before you ship it.
The preview lives inside an <iframe>. Three buttons in the toolbar resize the iframe to common mobile, tablet, and desktop widths. This matters: MUI's Grid breakpoints (xs, sm, md, lg, xl) react to the window they're rendered in. If you just rendered the preview in a <div> and shrank the div with CSS, MUI would still see the outer browser width and skip the breakpoint behavior entirely. Putting it inside an iframe gives it a real, smaller window — so xs={12} md={6} actually flips to two columns when you click "Desktop" and back to one when you click "Mobile."
If you only take one architectural idea from Bombie, take that one. "Use an iframe when you need an honest viewport" is worth the cost of the iframe.
The split between the two renderers is enforced by a RENDERERS switch in Preview/render-preview.js — one branch per supported component, with props.children mapped recursively through the same switch.
The schema-driven property editor
MUI components have a lot of props. Button alone has variant, color, size, disabled, disableElevation, fullWidth, href, startIcon, endIcon, and a handful more. A generic property editor that shows them all as labelled text inputs would technically work, but it would be unusable.
Bombie's editor reads a schema declared next to each component. Schemas group props into sections and give each prop a type — select, boolean, string, number, color — that the editor knows how to render. So Button's schema groups variant / color / size under "Appearance", disabled / disableElevation under "State", and href / startIcon / endIcon under "Behavior", and each field renders as the right control. The dialog adds a section per group, so even a high-cardinality component is navigable.
Schemas live next to the builder UI files (under Container/UI/) and are declared with makeLeafComponent or makeContainerComponent factories from UI/Common/make-component.js. The factories pair a schema with a render function, and the editor + the canvas read them both.
I'll cover authoring a new component in detail in the next post — it's about five small edits — but the schema is what makes the editing experience feel deliberate instead of dumped.
Drag and drop with type guards
Drag-and-drop on the canvas uses react-dnd. Two small abstractions wrap the primitives: DragBox (a draggable wrapper, used by the palette and by placed components) and DropBox (a drop target, used inside container components).
Each component in the catalog declares type (what kind of slot it can be dropped into) and accept (what it accepts as children). The accept rules prevent, for example, a TextField from being dropped directly into a Typography, and prevent layout containers from nesting things that don't make sense. react-dnd's useDrag and useDrop enforce these at the source — canDrop returns false and the cursor + outline change.
The element catalog (the source of truth for type and accept) lives in Data/element-base.js and Data/elements.js. Adding a new component means adding a row to those — see the next post for the full walkthrough.
Putting it together
The pieces talk to each other through the context provider:
ComponentGenerator (provider)
├── Elements/ (palette — sources of DragBox)
├── Container/ (canvas — recursive renderer, holds the tree)
│ └── UI/ (per-component builder files + property schemas)
├── Preview/ (iframe renderer for the same tree)
└── Samples/ (pre-built JSON trees that hydrate the canvas)
A drag from the palette dispatches appendChild on the tree. A click on the wrench opens the property editor, which dispatches setProp per change. A click on "Preview" passes the current tree through a postMessage to the iframe, which runs render-preview.js on it. A click on "Sample → Sign-in card" calls setTree(SAMPLES.signIn) and the canvas re-renders the whole thing.
Everything routes through that single tree state. There's no hidden parallel state for the canvas. Whatever the JSON says, that's what's on the screen.
What I'd change if I started over
A few things would be different in a v2:
- TypeScript from the start. The JSON-tree shape is dynamic, but the schemas are well-defined enough that they'd benefit from types. Today they're documented by example.
-
Schemas as the source of truth for renderers. Right now the schema (props the editor knows about) and the
renderfunction (props the canvas/preview actually uses) are kept in sync by hand. A v2 could derive both from one declaration. -
Tree as a flat map. Recursive immutable updates are O(depth) per change. A
Record<id, node>plus aRecord<id, childIds>would let every mutation be O(1).
None of these are urgent — Bombie is small enough that the recursive walks finish in microseconds — but they're the directions a serious version would take.
What's next
In the next post I'll walk through adding a brand new component to Bombie end-to-end, covering exactly the five files you need to touch and showing how makeLeafComponent / makeContainerComponent keep the additions small. After that, a final post on the operational lessons — SPA deep links on GitHub Pages, the CSP-per-mode setup, and what bombie-three.vercel.app actually does on a cold load.
If you want to follow along, the repo is at github.com/amith-moorkoth/bombie and PRs / issues are welcome.
Links
- Repo: https://github.com/amith-moorkoth/bombie
- Live demo: https://bombie-three.vercel.app/
- Previous post: Introducing Bombie
- Next post: Add Your Own Component to Bombie in 5 Edits
Top comments (0)