Building a drag-and-drop builder to handle complex nested layouts feels like navigating through a maze. When my team embarked on developing a visual editor for an e-commerce CMS, we encountered unique state management challenges daily. Dealing with deeply nested component trees and a feature-rich selection model demanded more than just surface-level solutions.
Imagine this: you’re handling a dynamically nested layout where each component can be moved around, and you have to keep track of parent-child relationships efficiently. Here’s where the decision between normalized and nested state becomes crucial.
I opted for Zustand because of its simplicity and performance. Using a flat structure with entity maps:
import create from 'zustand';
interface Node {
id: string;
parentId: string | null;
children: string[];
}
interface State {
nodes: Record<string, Node>;
getNode: (id: string) => Node | undefined;
setNode: (node: Node) => void;
}
const useStore = create<State>((set) => ({
nodes: {},
getNode: (id) => set(state => state.nodes[id]),
setNode: (node) => set(state => ({
nodes: { ...state.nodes, [node.id]: node }
}))
}));
// Adding node
useStore.getState().setNode({ id: 'node1', parentId:'root', children: [] });
The advantage? Quick lookup by ID and easy updates, which makes operations like reparenting straightforward. However, you need to derive the actual tree structure from this flat state every render — a trade-off worth considering for large datasets.
By deriving trees from a flat state, you can optimize further:
function deriveTree(rootId: string): Node {
const store = useStore.getState();
const rootNode = store.getNode(rootId);
if (!rootNode) return { id: '', parent: '', children: [] };
const queue: Node[] = [rootNode];
const result: Node[] = [];
while (queue.length) {
const node = queue.pop();
if (!node) continue;
node.children.forEach((childId) => {
const childNode = store.getNode(childId);
if (childNode) queue.push(childNode);
});
result.push(node);
}
return result;
}
It’s like building a snapshot without persistently storing an expansive object in state. It works for our use case, as users typically interact with a limited view of the overall dataset.
Rendered tree structures can get unwieldy fast, especially when state changes cascade. Here’s where React.memo and key management helped us mitigate unnecessary renders:
const TreeNode = React.memo(({ nodeId }: { nodeId: string }) => {
const node = useStore((state) => state.getNode(nodeId));
return (
<div key={node?.id}>
{
node?.children.map((childId) => (
<TreeNode key={childId} nodeId={childId} />
))
}
</div>
);
});
Honestly, leveraging React.memo can feel like magic in reducing workload on React’s reconciliation process. However, we learned this the hard way: stability in nodes’ keys is paramount.
Our selection model was wild, supporting single, multi, and range selection across nesting levels. Zustand worked beautifully, as it allowed dynamic injection of selection logic.
const useSelectionStore = create((set) => ({
selectedIds: new Set<string>(),
toggleSelect: (id: string) =>
set((state) => {
if (state.selectedIds.has(id)) {
state.selectedIds.delete(id);
} else {
state.selectedIds.add(id);
}
}),
rangeSelect: (startId: string, endId: string) =>
set((state) => {
// Implement range selection logic here
}),
}));
useSelectionStore.getState().toggleSelect('node1');
One weekend, after deploying an update, our selections weren’t respecting tree boundaries, bleeding into sibling containers. Turns out, we were ignoring node depth while deriving ranges. Lesson learned: always account for context in hierarchy-based selections.
Using dnd-kit, we resolved complex drop zone calculations:
import { useDroppable } from '@dnd-kit/core';
function DraggableItem({ id }: { id: string }) {
const { isOver, setNodeRef } = useDroppable({
id,
});
return (
<div ref={setNodeRef} style={{ backgroundColor: isOver ? 'lightblue' : 'white' }}>
Drag Me
</div>
);
}
Correcting a bug here involved accurately reflecting the real-time status (isOver) of each component, ensuring drop zones correctly visualized interactive states.
Initially, our drops lead to catastrophic reorder logic. The items didn’t respect sibling relationships — a quick deep dive revealed that order calculations weren’t accounting for transformed node lengths after dropping. We refined the logic to update sibling indices dynamically.
Maintaining state history for seamless undo/redo was crucial:
const useHistoryStore = create((set) => ({
past: [],
present: null,
future: [],
undo: () => set((state) => {
if (state.past.length === 0) return;
const previous = state.past[state.past.length - 1];
set({ present: previous, past: state.past.slice(0, -1), future: [state.present, ...state.future] });
}),
redo: () => set((state) => {
if (state.future.length === 0) return;
const next = state.future[0];
set({ present: next, past: [...state.past, state.present], future: state.future.slice(1) });
})
}));
We kept it simple, relying on Zustand’s set method to manage state transitions while tracking past/present/future pointers.
Achieving performance milestones was imperative. With deep prop chains in our tree, selective updates were a must. React’s built-in optimizations with useCallback and useMemo were our allies:
const memoizedCallback = useCallback(() => {
console.log('Expensive compute');
}, [dependency]);
useEffect(() => {
memoizedCallback();
}, [memoizedCallback]);
Moreover, debouncing tree mutations involved delaying persistence actions, so the UI never buckled under pressure.
If you’re venturing into drag-and-drop interfaces, here’s what I’ve learned:
Every step of the way taught us something new, right from selection anomalies to architecture halts weeks later due to minute logic gaps. Thanks for reading. If anything resonates or you’d like to chat more, feel free to reach out — I’m all ears for architecture debates. See you in the next one.