How I Made devUML - Part 1: The Magic of Bidirectional Sync Between Code and Diagrams
A deep dive into how devUML maintains perfect sync between code and visuals

Girish Koundinya
One of the most frustrating problems I've encountered as a software engineer is keeping diagrams in sync with code. We've all been there - you spend hours crafting the perfect UML diagram, only to have it become obsolete the moment someone refactors a class or adds a new API endpoint.
In traditional workflows, your diagrams are essentially dead the moment you create them. They're static artifacts that start drifting away from reality as soon as the actual implementation begins. This creates a perpetual documentation debt that nobody wants to pay down.
When I started building devUML, I knew solving this synchronization problem would be essential. I wanted to create a tool where code and diagrams truly understood each other - where changes in one would automatically reflect in the other. Here's how I approached it.
The Bidirectional Sync Challenge
First, let me clarify what I mean by "bidirectional sync." I wanted a system where:
- Visual changes update the code - When you drag a node or add a connection in the visual editor, the underlying code representation updates instantly
- Code changes update the visual diagram - When you modify the text-based diagram code, the visual representation rebuilds automatically
- Preserved context - User customizations like node positions should persist through edit cycles
- Real-time updates - No "generate" or "parse" buttons - changes flow naturally in both directions
This is fundamentally harder than it sounds because you're dealing with two completely different paradigms - visual spatial relationships and text-based syntax.
The Technical Architecture
I chose Mermaid.js as the foundation for the diagramming language. It's text-based, has a simple syntax, and can represent complex relationships. Most importantly, it's developer-friendly.
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
flowchart: { useMaxWidth: false }
});
By initializing Mermaid without auto-start, I gained control over the parsing and rendering lifecycle, which was critical for the bidirectional flow.
The Parser: From Code to Visualization
The first direction of sync is from Mermaid code to visual diagrams. Here's the core of how that works:
parseDiagram: async () => {
set({ hasChanges: false });
try {
const parser = (await mermaid.mermaidAPI.getDiagramFromText(get().code)).parser.yy;
const vertices = parser.getVertices();
const edges = parser.getEdges();
const nodes = [];
for (const [id, vertex] of vertices) {
nodes.push({
id,
type: 'default',
position: { x: 0, y: 0 },
data: {
label: vertex.text || id,
color: getNodeType(vertex)
}
});
}
const flowEdges = edges.map((edge, idx) => ({
id: `edge-${idx}`,
source: edge.start,
target: edge.end,
type: 'smoothstep'
}));
set({ nodes, edges: flowEdges });
store.setLayout('TB');
} catch (error) {
console.error('Parse failed:', error);
}
}
This function takes Mermaid code, parses it into vertices and edges, and then transforms those into React Flow nodes and connections. This gives us a visually interactive diagram that represents the code.
A key insight here: Mermaid's parser doesn't just validate syntax, it builds an internal representation of the diagram that we can extract and transform.
The Converter: From Visualization to Code
The reverse direction - turning visual changes back into code - is equally important:
const convertToMermaid = (nodes, edges) => {
let code = 'flowchart TB\n';
nodes.forEach(node => {
code += ` ${node.id}[${node.data.label}]\n`;
});
edges.forEach(edge => {
code += ` ${edge.source} --> ${edge.target}\n`;
});
return code;
};
This function takes our visual nodes and edges and converts them back into valid Mermaid syntax. When users drag nodes around or connect them in the UI, we capture those changes and update the code.
The Event Flow: Keeping Everything in Sync
The real magic happens in the event handlers that tie these two sides together:
onNodesChange: (changes) => {
const nodes = applyNodeChanges(changes, get().nodes);
set({ nodes });
const code = convertToMermaid(nodes, get().edges);
set({ code, hasChanges: true });
},
onEdgesChange: (changes) => {
const edges = applyEdgeChanges(changes, get().edges);
set({ edges });
const code = convertToMermaid(get().nodes, edges);
set({ code, hasChanges: true });
},
onConnect: (connection) => {
const edges = addEdge(connection, get().edges);
set({ edges });
const code = convertToMermaid(get().nodes, edges);
set({ code, hasChanges: true });
},
These React Flow event handlers capture any visual changes, update the internal state, and then regenerate the Mermaid code. The code generation happens on every single change, ensuring the text representation is always in sync with what the user sees.
The State Management Challenge
To make all this work smoothly, I needed a unified state management approach. I opted for Zustand, which provides a simple but powerful store:
const useDiagramStore = create((set, get) => {
const store = {
code: '',
nodes: [],
edges: [],
view: 'split',
hasChanges: false,
// ... all the methods we've discussed
};
return store;
});
This centralized store holds both the code and the visual representation, making it the single source of truth for the entire application. When either side changes, the store updates, triggering reactions that keep everything in sync.
Preserving Context Through Edits
One of the hardest challenges was preserving user intent and layout through edit cycles. If you've positioned your nodes just right, you don't want them randomly rearranged when you edit the code.
My solution: storing node positions separately from the diagram structure.
updateNodePositions: (savedPositions) => {
const currentNodes = get().nodes;
const updatedNodes = currentNodes.map(node => {
const savedNode = savedPositions.find(pos => pos.id === node.id);
if (savedNode) {
return {
...node,
position: savedNode.position,
data: {
...node.data,
...savedNode.data
}
};
}
return node;
});
set({ nodes: updatedNodes });
},
When saving a diagram, we store not just the Mermaid code but also the positions of all nodes. When loading or updating a diagram, we restore these positions, preserving the user's layout.
The Split View: Seeing Both Sides
The interface needed to reflect this bidirectional nature, which led to the split view mode:
{view === 'split' && (
<>
<div className="w-[28%] border-r">
<CodeView />
</div>
<div className="w-[47%]">
<DiagramEditor initialDiagram={initialDiagram} />
</div>
</>
)}
This split view lets users see and edit both representations simultaneously. Edit the code, watch the diagram update. Drag a node, watch the code change. It creates a powerful feedback loop that helps users understand both the visual and textual aspects of their diagrams.
The Results: Bridging the Gap
The final result is a seamless experience where code and diagrams feel like two views of the same thing, rather than separate artifacts that need to be manually synchronized. This changes how people work with diagrams in several ways:
- Reduced maintenance overhead - Update in one place, and the other format follows
- Better understanding - See the relationship between visual and textual representations
- Code-friendly workflows - The text-based format works naturally with version control and code review
- Flexible editing - Choose the interface that works best for your current task
What's Next
Building this bidirectional sync system was just the first step in creating devUML. In the next post, I'll explore how I implemented version control to track changes over time and enable collaboration. After that, we'll dive into how I integrated AI to make diagram creation more intuitive and accessible.
The journey of building devUML has been full of technical challenges, but seeing users effortlessly maintain synchronized diagrams has made it all worthwhile. As software gets more complex, having tools that bridge the gap between different representations becomes increasingly important.
Stay tuned for Part 2, where I'll explain how I built version control for diagrams from the ground up.
