Node Editor Architecture Overview
Designing the XGen canvas editor required hitting both extensibility and maintainability at the same time. This post covers the architecture layers built on top of React Flow in detail.
State Management: Zustand Store Design
Zustand manages canvas state globally. Redux carried too much boilerplate, and Context API raised concerns about unnecessary re-renders.
// stores/canvasStore.ts
interface CanvasState {
nodes: Node[];
edges: Edge[];
selectedNodeId: string | null;
// Actions
addNode: (node: Node) => void;
updateNodeData: (nodeId: string, data: Partial<NodeData>) => void;
removeNode: (nodeId: string) => void;
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
onConnect: OnConnect;
}
export const useCanvasStore = create<CanvasState>((set, get) => ({
nodes: [],
edges: [],
selectedNodeId: null,
addNode: (node) => set((state) => ({
nodes: [...state.nodes, node]
})),
updateNodeData: (nodeId, data) => set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n
),
})),
// ...
}));
Node Type Registry Pattern
With more node types expected to be added continuously, a registry pattern made sense.
// registry/nodeRegistry.ts
interface NodeDefinition {
type: string;
label: string;
category: 'llm' | 'retrieval' | 'agent' | 'tool' | 'utility';
component: React.ComponentType<NodeProps>;
defaultData: Record<string, unknown>;
inputs: PortDefinition[];
outputs: PortDefinition[];
}
const nodeRegistry = new Map<string, NodeDefinition>();
export function registerNode(definition: NodeDefinition) {
nodeRegistry.set(definition.type, definition);
}
export function getNodeDefinition(type: string) {
return nodeRegistry.get(type);
}
Port Compatibility Validation
Type compatibility checking at connection time is critical. For example, if an LLM node outputs a text type but a VectorStore input expects an embedding type, a direct connection should not be allowed.
type PortType = 'text' | 'embedding' | 'document' | 'any' | 'chat_message';
function isConnectionValid(
sourcePort: PortDefinition,
targetPort: PortDefinition
): boolean {
if (targetPort.type === 'any' || sourcePort.type === 'any') return true;
return sourcePort.type === targetPort.type;
}
Serialization and Workflow Persistence
Saving canvas state to the server required a serialization strategy. A dedicated layer strips UI state out of React Flow's node and edge data, extracting only the pure workflow data. This keeps the backend interface clean.
Performance Optimization
Performance degradation became noticeable beyond 50 nodes. The solution was aggressive use of React.memo, useMemo, and useCallback, combined with simplified rendering for nodes outside the viewport.