A mini visual flow builder built using React + TypeScript + React Flow (@xyflow/react) that allows users to create, connect, edit, validate, and persist conversational flows.
This project focuses on clean architecture, extensibility, and correct business rule enforcement.
Add your deployed Vercel link here
| Technology | Purpose |
|---|---|
| React | UI framework |
| TypeScript | Type safety |
| Vite | Build tool & dev server |
| @xyflow/react (React Flow v12) | Visual canvas & node rendering |
| Zustand | Centralized state management |
| Zustand Persist Middleware | localStorage persistence |
- Drag "Message Node" from sidebar
- Drop anywhere on canvas
- Node created at exact drop position using
screenToFlowPosition
Each node contains:
- Title
- Message content
- Source handle (right side β for outgoing connections)
- Target handle (left side β for incoming connections)
- Connect nodes visually by dragging between handles
- Only one outgoing edge per source node allowed
- Multiple incoming edges allowed on any node
- Click any node β settings panel opens in the sidebar
- Edit node title and message content live
- Updates reflect instantly on the canvas
- Delete selected node (also removes all connected edges)
- Delete edges by selecting and pressing the
Deletekey
On clicking Save, the following rule is enforced:
If total nodes > 1 AND more than one node has no incoming edge β β Flow is invalid
Only one root node is allowed β ensuring a single entry point for the conversational flow.
Flow state persists automatically using Zustand + localStorage. Refreshing the page does not lose the flow.
src/
β
βββ app/
β βββ FlowBuilder.tsx # Root layout: canvas + sidebar
β
βββ components/
β βββ canvas/
β β βββ FlowCanvas.tsx # React Flow canvas, drag/drop, event handlers
β βββ nodes/
β β βββ TextNode.tsx # Custom message node UI
β βββ panels/
β β βββ Sidebar.tsx # Conditional panel router
β β βββ NodesPanel.tsx # Draggable node palette
β β βββ SettingsPanel.tsx # Node edit form
β βββ common/
β βββ SaveButton.tsx # Save + validation trigger
β
βββ store/
β βββ flowStore.ts # Zustand store (nodes, edges, UI state)
β
βββ registry/
β βββ nodeRegistry.ts # Maps node type strings β React components
β
βββ types/
β βββ flowTypes.ts # Edge, Node, store typings
β βββ nodeTypes.ts # Node data shape definitions
β
βββ utils/
βββ validation.ts # Business rule enforcement (pure function)
React Flow runs in controlled mode:
nodesandedgesare owned by Zustand- React Flow is responsible for UI rendering only
- All mutations (add, update, delete) go through the centralized store
This ensures predictable, debuggable state management with a single source of truth.
All application state lives in store/flowStore.ts:
{
nodes: Node[],
edges: Edge[],
selectedNodeId: string | null,
panelMode: 'nodes' | 'settings',
}State is:
- Immutable β updated via Zustand actions, never mutated directly
- Persisted β via
persistmiddleware to localStorage - Globally accessible β no prop drilling needed
Custom node types are registered in registry/nodeRegistry.ts:
export const nodeTypes = {
textNode: TextNode,
};To add a new node type:
- Create a new node component in
components/nodes/ - Register it in
nodeRegistry.ts - Add a drag item for it in
NodesPanel.tsx
The FlowCanvas does not need to be modified.
Validation logic is fully isolated in utils/validation.ts as a pure function:
validateFlow(nodes, edges): { valid: boolean; message: string }Benefits:
- UI components stay clean and presentation-only
- Logic is independently unit-testable
- Responsibilities are clearly separated
The validation algorithm works as follows:
- Build a map of
nodeId β incomingEdgeCount(all start at 0) - Iterate through all edges and increment the target node's count
- Count nodes where
incomingEdgeCount === 0(root nodes) - If
nodes.length > 1ANDrootCount > 1β invalid flow
This ensures a valid single-entry-point conversational flow.
- Drag node type from sidebar palette
- Drop on canvas
- Canvas computes position via
screenToFlowPosition - Node added to Zustand store β canvas re-renders
- User drags from a source handle to a target handle
onConnectfires- Logic checks: does source node already have an outgoing edge?
- If not β edge added to store
- User clicks a node
selectedNodeIdset in store β panel switches to settings mode- User edits title/message in the settings panel
- Store updates node data β node re-renders live
- User clicks Save
validateFlow(nodes, edges)runs- Valid β success toast/indicator shown
- β Invalid β error message shown
Zustand's persist middleware automatically syncs state to localStorage:
persist(flowStore, { name: 'flow-builder-storage' })On page refresh:
- Zustand reads from
flow-builder-storagein localStorage - State is rehydrated
- Flow is restored automatically β no manual save required
# Install dependencies
npm install
# Start dev server
npm run devBuild for production:
npm run build| Feature | Description |
|---|---|
| Auto layout | DAG-based automatic node positioning |
| JSON export/import | Save and load flows as files |
| Multiple flows | Support for multiple independent flows |
| Backend persistence | API-based storage replacing localStorage |
| Visual validation indicators | Highlight invalid nodes/edges before save |
| Edge labels | Named connections for conditional branches |
| Conditional branching | Nodes with multiple conditional outputs |
| Undo/Redo | History-based state management |
This project was built with the following principles in mind:
- Clean separation of concerns β UI, state, and logic are independent layers
- Extensibility β new node types can be added with zero changes to core components
- Strict TypeScript β all data structures are fully typed
- Business rule enforcement β validation is explicit, not implicit
- Predictable state management β controlled React Flow + Zustand
- Minimal coupling β components communicate through the store, not each other
The goal was not just to make it work, but to structure it as a scalable, maintainable system.