Booking Flow Builder
Introduction
The Wrkbelt Booking Flow Builder is a visual, canvas-based editing system for constructing multi-step booking experiences. Built on ReactFlow and following Kent Dodds' composition patterns, the builder enables users to design complex booking workflows through drag-and-drop interactions with real-time validation, analytics, and sophisticated state management.
Key Capabilities
- Visual Canvas Editing: ReactFlow-powered drag-and-drop interface
- Unified Context API: Single
useFlowCanvas()hook for all flow operations - Composing Provider Pattern: BookingFlowBuilderProvider wraps all feature contexts
- Compound Component Architecture: Reusable, composable node components following Radix UI patterns
- Unidirectional Data Flow: Single source of truth with predictable state management
- Real-Time Validation & Analytics: Continuous validation with flow metrics and insights
- Auto-Save with Draft Recovery: Debounced auto-save with session storage crash protection
- Service Selection Graphs: Nested question/answer flows with graph validation
- Advanced Controls: Auto-layout, node expansion, fullscreen, minimap toggle
- Inline Editing: All node configuration happens in-place on the canvas
- Performance Optimized: Extensive memoization and selective rendering
- TypeScript-First: Types over interfaces, full type safety throughout
Architecture Overview
System Context
Component Hierarchy
BookingFlowBuilderProvider (Composing App Provider)
├── FlowCanvasProvider (Canvas + ReactFlow State Management)
│ ├── FlowPaletteProvider (Palette State Management) [Optional]
│ │ └── FlowPalette (Palette Drawer UI)
│ │ ├── FlowPaletteHeader (Search & Title)
│ │ ├── NodeTypeList (Workflow Steps)
│ │ │ └── NodeTypeAccordionItem
│ │ │ ├── Create New Button
│ │ │ ├── Search Input
│ │ │ ├── Sort Dropdown (Most Recent/Most Used/Alphabetical)
│ │ │ └── EntityCard (Existing Entities - with usage_count badge)
│ │ └── NodeTypeList (Service Flow Builder)
│ │
│ ├── ValidationPanelProvider (Validation State Management) [Optional]
│ │ └── ValidationPanel (Validation Drawer UI)
│ │ ├── Tabs (Issues / Analytics)
│ │ ├── IssuesTab (Validation errors & warnings)
│ │ └── AnalyticsTab (Flow metrics & insights)
│ │
│ └── FlowCanvas (Canvas UI Rendering)
│ ├── ReactFlow (Graph Engine)
│ │ ├── Step-Type Nodes
│ │ │ ├── ZipCodeNode
│ │ │ ├── ServiceSelectionNode
│ │ │ ├── AdditionalDetailsNode
│ │ │ ├── TimeslotSelectionNode
│ │ │ ├── CustomerInfoNode
│ │ │ └── SummaryNode
│ │ │
│ │ └── Service Selection Sub-Nodes
│ │ ├── QuestionNode
│ │ ├── AnswerCategoryNode
│ │ ├── TextAnswerNode
│ │ └── ServiceAnswerNode
│ │
│ ├── FlowControls (Built-in Controls)
│ │ ├── FullscreenControl
│ │ ├── MinimapControl
│ │ ├── AutoLayoutControl (Popover)
│ │ └── NodeExpansionControl (Popover)
│ │
│ ├── BookingFlowMinimap (Themed Minimap)
│ ├── CanvasSlots (Consumer Slots)
│ └── Background
│
└── useFlowCanvas() (Unified Hook: Context + ReactFlow instance)
Core Principles
1. Unidirectional Data Flow
Following Kent Dodds and React best practices, all state flows through dual context providers with clear separation of concerns.
Canvas Data Flow
FlowCanvasProvider (Canvas State)
↓
Context (nodes, edges, handlers)
↓
Components (Read via useFlowCanvas)
↓
User Actions (drag, connect, edit)
↓
Callbacks (onNodesChange, onEdgesChange)
↓
FlowCanvasProvider (Canvas State) ← Loop
Palette Data Flow
FlowPaletteProvider (Palette State)
↓
Context (nodeData, search, filters)
↓
Components (Read via useFlowPalette)
↓
User Actions (search, select, create)
↓
Callbacks (onCreateNode, onSelectEntity)
↓
Consumer (Adds node to canvas via FlowCanvas)
Critical Rules
Canvas State:
- Always use
useFlowCanvas()- This is the unified public API for the entire flow builder useFlowCanvas()composes both FlowCanvasContext AND ReactFlow'suseReactFlow()into a single hook- FlowCanvasProvider wraps ReactFlowProvider - prevents double-wrapping issues
- All ReactFlow instance methods (
fitView,zoomIn,getNode, etc.) are available throughuseFlowCanvas()
// ✅ CORRECT - Unified API with everything
const {
// FlowCanvas custom state
nodes,
edges,
setNodes,
addNode,
toggleMinimap,
toggleFullScreen,
canvasRef,
// ReactFlow instance methods (composed automatically)
fitView,
zoomIn,
zoomOut,
getNode,
getEdge,
screenToFlowPosition,
// ... all other ReactFlow methods
} = useFlowCanvas();
// ❌ WRONG - Bypasses composition, defeats unified API
const { setNodes } = useReactFlow();
const canvas = useFlowCanvas(); // Now you have two separate hooks!
Palette State:
- Always use
useFlowPalette()within FlowPaletteProvider. - Palette is purely presentational - consumer handles node creation.
// ✅ CORRECT - Context-driven
const { nodeData, onCreateNode } = useFlowPalette();
// ❌ WRONG - Prop drilling, tight coupling
<FlowPalette nodeData={data} onCreateNode={handler} />
2. Compound Component Pattern
Following Radix UI and shadcn/ui patterns:
<GenericNode data={data} id={id}>
<GenericNode.Root>
<GenericNode.HeaderCollapsible config={headerConfig}>
<GenericNode.CollapseTrigger />
</GenericNode.HeaderCollapsible>
<GenericNode.Body>
<GenericNode.Section icon={Edit3} title="Configuration">
{/* Form fields */}
</GenericNode.Section>
</GenericNode.Body>
<GenericNode.Footer>
<GenericNode.InfoBox title="Help">Content</GenericNode.InfoBox>
</GenericNode.Footer>
<GenericNode.Handle type="source" position={Position.Right} />
</GenericNode.Root>
</GenericNode>
3. Controlled Component with Loop Prevention
Nodes implement bidirectional data sync:
export function useMyNode() {
const { data, updateData } = useGenericNode<MyNodeData>();
// Loop prevention
const isInternalUpdateRef = useRef(false);
const prevDataRef = useRef<string>('');
const form = useForm({
resolver: zodResolver(schema),
defaultValues: data.myNodeData,
});
// Form → Parent (optimistic updates)
useEffect(() => {
const subscription = form.watch((values) => {
isInternalUpdateRef.current = true;
updateData({ myNodeData: values });
});
return () => subscription.unsubscribe();
}, [form, updateData]);
// Parent → Form (external updates)
useEffect(() => {
if (!data.myNodeData) return;
const currentDataStr = JSON.stringify(data.myNodeData);
if (currentDataStr === prevDataRef.current) return;
if (isInternalUpdateRef.current) {
isInternalUpdateRef.current = false;
prevDataRef.current = currentDataStr;
return;
}
prevDataRef.current = currentDataStr;
form.reset(data.myNodeData);
}, [data.myNodeData, form]);
return { form };
}
4. No Individual Save Buttons
Following modern collaborative editing (Google Docs, Figma):
- ✅ Nodes update immediately via
form.watch() - ✅ Parent handles all saving with debounced auto-save
- ✅ No manual save buttons
- ✅ Global save status indicator
5. TypeScript Types Over Interfaces
CRITICAL CODING PREFERENCE: Always use TypeScript type over interface.
// ✅ CORRECT
export type MyNodeData = {
readonly field: string;
};
export type MyComponentProps = {
data: MyNodeData;
onSelect?: (id: string) => void;
};
// ❌ WRONG
interface MyNodeData {
field: string;
}
Provider Architecture
BookingFlowBuilderProvider
The BookingFlowBuilderProvider is a composing "App Provider" that wraps all booking flow builder contexts in the correct order. It follows Kent Dodds' compound component patterns and Tanner Linsley's provider composition patterns.
Features
- Single Provider: One provider for the entire feature
- Smart Composition: Automatically orders providers correctly
- Optional Features: Only includes providers you need
- Type-Safe: Full TypeScript support
- Progressive Disclosure: Simple cases are simple, complex cases are possible
Provider Hierarchy
The provider composes these contexts in order (outermost → innermost):
- FlowCanvasProvider (required) - Includes ReactFlowProvider, manages nodes/edges
- FlowPaletteProvider (optional) - Node palette drawer and selection
- ValidationPanelProvider (optional) - Validation panel drawer and state
Basic Usage
import { BookingFlowBuilderProvider } from '@wrkbelt/ui-components';
function App() {
return (
<BookingFlowBuilderProvider
canvas={{
initialNodes: [],
initialEdges: [],
}}
>
<FlowCanvas />
</BookingFlowBuilderProvider>
);
}
With All Features
function App() {
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isValidationOpen, setIsValidationOpen] = useState(false);
return (
<BookingFlowBuilderProvider
canvas={{
initialNodes: myNodes,
initialEdges: myEdges,
isValidConnection: validateConnection,
}}
palette={{
isOpen: isPaletteOpen,
onOpenChange: setIsPaletteOpen,
onCreateNode: handleCreateNode,
onSelectEntity: handleSelectEntity,
initialNodeData: myNodeData,
}}
validation={{
isDrawerOpen: isValidationOpen,
onDrawerOpenChange: setIsValidationOpen,
validationState: validationState,
analytics: flowAnalytics,
onNodeFocus: handleNodeFocus,
}}
>
<FlowCanvas>
<FlowPalette />
<ValidationPanel />
</FlowCanvas>
</BookingFlowBuilderProvider>
);
}
API Reference
| Prop | Type | Required | Description |
|---|---|---|---|
canvas | BookingFlowCanvasConfig | ✅ | Canvas/Flow configuration |
palette | BookingFlowPaletteConfig | ❌ | Node palette configuration |
validation | BookingFlowValidationConfig | ❌ | Validation panel configuration |
Built-In Components
FlowControls
Compound component that provides canvas control functionality. Automatically included in FlowCanvas.
Sub-Components
| Component | Description | Type |
|---|---|---|
FullscreenControl | Toggle fullscreen mode | Button |
MinimapControl | Toggle minimap visibility | Button |
AutoLayoutControl | Auto-layout configuration | Popover |
NodeExpansionControl | Expand/collapse all nodes | Popover |
Composition
import { FlowControls, FullscreenControl, MinimapControl } from '@wrkbelt/ui-components';
// Use full component
<FlowControls />
// Or compose individual controls
<Controls>
<FullscreenControl />
<MinimapControl />
<AutoLayoutControl />
<NodeExpansionControl />
</Controls>
Features
- Auto-Layout: Horizontal/Vertical dagre layouts with configurable spacing
- Node Expansion: Expand/collapse all nodes globally
- Fullscreen: Enter/exit fullscreen with proper popover portal handling
- Minimap Toggle: Show/hide themed minimap
Popover Portal Fix
Controls use portalContainer to ensure popovers appear correctly in fullscreen mode:
export const AutoLayoutControl: React.FC = () => {
const { canvasRef } = useFlowCanvas();
return (
<Popover>
<PopoverContent
portalContainer={canvasRef.current || undefined}
>
{/* Content renders inside canvas container, visible in fullscreen */}
</PopoverContent>
</Popover>
);
};
BookingFlowMinimap
Custom-themed minimap component with booking flow-specific colors.
Features
- Themed Colors: Pre-configured color scheme matching the design system
- Full Customization: All ReactFlow MiniMap props supported
- Zero State: Pure presentational component
- Drop-in Replacement: Works exactly like ReactFlow's MiniMap
Default Colors
| Property | Value | Description |
|---|---|---|
bgColor | #E8EDF5 | Light blue background |
nodeColor | #F5F6F8 | Off-white for nodes |
nodeStrokeColor | #CBD5E1 | Slate-300 for borders |
position | top-left | Default placement |
Usage
import { BookingFlowMinimap } from '@wrkbelt/ui-components';
// Basic usage with defaults
<BookingFlowMinimap />
// Custom position
<BookingFlowMinimap position="bottom-right" />
// Custom colors
<BookingFlowMinimap
bgColor="#custom-bg"
nodeColor="#custom-node"
/>
ValidationPanel
Real-time validation and analytics panel with tabbed interface.
Features
- Issues Tab: Displays validation errors and warnings by category
- Analytics Tab: Shows flow metrics and optimization insights
- Categorization: Groups issues by type (Graph, Node, Config)
- Node Focus: Click to pan/zoom to problematic nodes
Analytics Metrics
Click Metrics:
- Min Clicks
- Max Clicks
- Avg Clicks
Service Selection Metrics (optional):
- Answer Options Range (min/max) - Scrolling proxy
- Question Path Count Range (min/max) - Decision depth
Analytics Types
type ClickMetrics = {
readonly minClicks: number;
readonly maxClicks: number;
readonly avgClicks: number;
};
type ServiceSelectionMetrics = {
readonly minAnswerOptions: number; // Scrolling proxy
readonly maxAnswerOptions: number; // Scrolling proxy
readonly minQuestionCount: number; // Subset of clicks
readonly maxQuestionCount: number; // Subset of clicks
};
type FlowAnalytics = {
readonly clickMetrics: ClickMetrics;
readonly serviceSelectionMetrics?: ServiceSelectionMetrics;
readonly totalPaths: number;
readonly totalNodes: number;
readonly totalConnections: number;
};
Usage
import { ValidationPanel, ValidationPanelProvider } from '@wrkbelt/ui-components';
const validationState = {
isValid: false,
issues: [/* validation issues */],
};
const analytics = {
clickMetrics: {
minClicks: 3,
maxClicks: 7,
avgClicks: 4.5,
},
serviceSelectionMetrics: {
minAnswerOptions: 2,
maxAnswerOptions: 8,
minQuestionCount: 1,
maxQuestionCount: 4,
},
totalPaths: 12,
totalNodes: 8,
totalConnections: 10,
};
<ValidationPanelProvider
validationState={validationState}
analytics={analytics}
isDrawerOpen={isOpen}
onDrawerOpenChange={setIsOpen}
onNodeFocus={handleNodeFocus}
>
<ValidationPanel />
</ValidationPanelProvider>
Node Types Reference
Step-Type Nodes
| Node | Purpose | Key Configuration |
|---|---|---|
| ZipCodeNode | Service area validation | Zip list, error messaging |
| ServiceSelectionNode | Service/question selection | Contains service selection graph |
| AdditionalDetailsNode | Optional details | Field configuration |
| TimeslotSelectionNode | Appointment scheduling | Availability rules, buffers |
| CustomerInfoNode | Contact information | Required/optional toggles |
| SummaryNode | Confirmation screen | Success messaging |
Service Selection Sub-Nodes
| Node | Purpose | Connections |
|---|---|---|
| QuestionNode | Branch point | Outputs to answers |
| AnswerCategoryNode | Answer grouping | Groups related answers |
| TextAnswerNode | Simple text answer | To next question or terminate |
| ServiceAnswerNode | Service-linked answer | To service, terminates |
Implementation Guide
Creating a New Node
File Structure
node-types/my-node/
├── my-node.tsx # Component
├── my-node.config.tsx # Configuration & defaults
├── my-node.stories.tsx # Storybook
├── use-my-node.tsx # Business logic hook
├── schemas.ts # Zod validation
└── types.ts # TypeScript types
1. Types (types.ts)
import { BookingFlowStepType } from '@wrkbelt/shared/types-data';
import type { GenericNodeData } from '../../generic-node/types.generic-node';
type MyNodeData = {
myNodeData: {
readonly step_type: BookingFlowStepType.MY_STEP;
step_config: { title: string; description?: string };
type_config: { /* your config */ };
};
};
export type MyNodeData = GenericNodeData<MyNodeData>;
export type MyFormSchema = {
title: string;
description?: string;
};
2. Validation (schemas.ts)
import { z } from 'zod';
export const myNodeSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
});
3. Configuration (my-node.config.tsx)
import { BookingFlowStepType } from '@wrkbelt/shared/types-data';
import { MyIcon } from 'lucide-react';
export const MY_NODE_CONFIG = {
label: 'My Step',
description: 'What this step does',
theme: {
icon: MyIcon,
colors: {
bg: 'bg-blue-500' as const,
border: 'border-blue-200' as const,
},
},
myNodeData: {
step_type: BookingFlowStepType.MY_STEP as const,
step_config: { title: '', description: '' },
type_config: {},
},
} satisfies Partial<MyNodeData>;
export function createMyNodeData(overrides = {}): Partial<MyNodeData> {
const merged = deepMerge(MY_NODE_CONFIG, overrides);
// Enforce step_type
if (merged.myNodeData) {
merged.myNodeData.step_type = BookingFlowStepType.MY_STEP;
}
return merged;
}
4. Hook (use-my-node.tsx)
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useGenericNode } from '../../generic-node/hooks/use-generic-node';
import { myNodeSchema } from './schemas';
import type { MyFormSchema, MyNodeData } from './types';
export function useMyNode() {
const { data, updateData } = useGenericNode<MyNodeData>();
const { myNodeData } = data;
const isInternalUpdateRef = useRef(false);
const prevDataRef = useRef<string>('');
const form = useForm<MyFormSchema>({
resolver: zodResolver(myNodeSchema),
mode: 'onChange',
defaultValues: {
title: myNodeData?.step_config?.title || '',
description: myNodeData?.step_config?.description || '',
},
});
// Form → Parent
useEffect(() => {
const subscription = form.watch((values) => {
isInternalUpdateRef.current = true;
updateData({
myNodeData: {
step_type: BookingFlowStepType.MY_STEP,
step_config: {
title: values.title ?? '',
description: values.description ?? '',
},
type_config: {},
},
});
});
return () => subscription.unsubscribe();
}, [form, updateData]);
// Parent → Form
useEffect(() => {
if (!myNodeData) return;
const currentDataStr = JSON.stringify(myNodeData);
if (currentDataStr === prevDataRef.current) return;
if (isInternalUpdateRef.current) {
isInternalUpdateRef.current = false;
prevDataRef.current = currentDataStr;
return;
}
prevDataRef.current = currentDataStr;
form.reset({
title: myNodeData.step_config?.title || '',
description: myNodeData.step_config?.description || '',
});
}, [myNodeData, form]);
const validate = useCallback(() => form.formState.isValid, [form.formState.isValid]);
return { form, validate };
}
5. Component (my-node.tsx)
import { Position } from '@xyflow/react';
import { Edit3 } from 'lucide-react';
import { memo, useMemo } from 'react';
import { Input } from '../../../shadcn-ui/input';
import { GenericNode, GenericNodeProps } from '../../generic-node';
import { NodeFormLabel } from '../../generic-node/components/features/form.generic-node';
import { createMyNodeData } from './my-node.config';
import { useMyNode } from './use-my-node';
const MyNodeContent = memo(function MyNodeContent() {
const { form } = useMyNode();
return (
<GenericNode.Root>
<GenericNode.Header>
<GenericNode.CollapseTrigger />
</GenericNode.Header>
<GenericNode.Body>
<GenericNode.Section icon={Edit3} title="Configuration" defaultOpen>
<div className="space-y-4">
<div className="space-y-2">
<NodeFormLabel id="title" label="Title" isRequired />
<Input
id="title"
{...form.register('title')}
placeholder="Enter title"
/>
{form.formState.errors.title && (
<p className="text-sm text-red-600">
{form.formState.errors.title.message}
</p>
)}
</div>
</div>
</GenericNode.Section>
</GenericNode.Body>
<GenericNode.Handle type="source" position={Position.Right} />
<GenericNode.Handle type="target" position={Position.Left} />
</GenericNode.Root>
);
});
export const MyNode = memo<GenericNodeProps<MyNodeData>>(
function MyNode(props) {
const { data, id } = props;
const mergedData = useMemo(
() => createMyNodeData({ ...data, nodeId: data.nodeId || id }) as MyNodeData,
[data, id]
);
return (
<GenericNode {...props} data={mergedData}>
<MyNodeContent />
</GenericNode>
);
}
);
Adding Collapse-Aware Headers
For headers that show different content when collapsed:
// In hook
import { useMemo } from 'react';
import { NodeHeaderCollapsibleConfig } from '../../generic-node/components/slots/header.generic-node';
import { MY_NODE_CONFIG } from './my-node.config';
export function useMyNode() {
// ... existing logic
const title = form.watch('title');
const headerConfig = useMemo<NodeHeaderCollapsibleConfig>(
() => ({
collapsed: {
title: MY_NODE_CONFIG.contentConfig.title,
description: title || MY_NODE_CONFIG.contentConfig.description,
icon: MyIcon,
iconClassName: 'bg-blue-500',
},
expanded: {
title: MY_NODE_CONFIG.contentConfig.title,
description: MY_NODE_CONFIG.contentConfig.description,
icon: MyIcon,
iconClassName: 'bg-blue-500',
},
}),
[title]
);
return { form, headerConfig, validate };
}
// In component
<GenericNode.HeaderCollapsible config={headerConfig}>
<GenericNode.CollapseTrigger />
</GenericNode.HeaderCollapsible>
State Management
Multi-Context Architecture
The system uses multiple independent context providers for clean separation of concerns, all composed via BookingFlowBuilderProvider.
FlowCanvasProvider (Canvas State)
Manages the ReactFlow canvas state. Note: useFlowCanvas() hook automatically composes this with ReactFlow's useReactFlow() for a unified API.
type FlowCanvasContextValue = {
// ReactFlow state (controlled or internal)
nodes: Node[];
edges: Edge[];
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
onConnect: (connection: Connection) => void;
// Direct state setters
setNodes: (nodes: Node[] | ((prev: Node[]) => Node[])) => void;
setEdges: (edges: Edge[] | ((prev: Edge[]) => Edge[])) => void;
// Canvas UI state
isFullScreen: boolean;
toggleFullScreen: () => void;
// Minimap visibility state
showMinimap: boolean;
toggleMinimap: () => void;
// Loading state
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
// Node management
addNode: (node: Node) => void;
removeNode: (id: string) => void;
// Drawer/palette state
isDrawerOpen: boolean;
openDrawer: () => void;
closeDrawer: () => void;
// Utilities
canvasRef: RefObject<HTMLDivElement | null>;
isValidConnection?: IsValidConnection<Edge>;
};
// useFlowCanvas() returns FlowCanvasContextValue + ReactFlowInstance
// This means you get ALL ReactFlow methods too:
// fitView, zoomIn, zoomOut, getNode, getEdge, screenToFlowPosition, etc.
FlowPaletteProvider (Palette State)
Manages the node palette/drawer state:
type FlowPaletteContextValue = {
// Global search
globalSearchTerm: string;
setGlobalSearchTerm: (term: string) => void;
// Per-node-type data (keyed by NodeType)
nodeData: Partial<Record<NodeType, NodeTypeData>>;
updateNodeData: (nodeType: NodeType, updates: Partial<NodeTypeData>) => void;
setNodeData: (data: Partial<Record<NodeType, NodeTypeData>>) => void;
// Business logic callbacks
onCreateNode: (nodeType: NodeType) => void;
onSelectEntity: (nodeType: NodeType, entityId: string) => void;
// Per-node search/sort callbacks
onNodeSearchChange: (nodeType: NodeType, term: string) => void;
onSortChange: (nodeType: NodeType, sortOption: EntitySortOption) => void;
// Drawer state (controlled)
isOpen: boolean;
onOpenChange: (open: boolean) => void;
portalContainer?: HTMLElement | null;
};
EntityLookups (Domain Data)
The EntityLookups type stores deduplicated existing entities for copy-on-use:
type EntityLookups = {
readonly isLoadingEntities: boolean;
readonly availableBookingServices: readonly AvailableBookingService[];
// Service Flow Entities
readonly existingQuestions: readonly PaletteEntity[];
readonly existingCategories: readonly PaletteEntity[];
readonly existingServiceAnswers: readonly PaletteEntity[];
readonly existingTextAnswers: readonly PaletteEntity[];
// Workflow Step Entities (all step types)
readonly existingZipcodeSteps: readonly PaletteEntity[];
readonly existingServiceSelectionSteps: readonly PaletteEntity[];
readonly existingAdditionalDetailsSteps: readonly PaletteEntity[];
readonly existingTimeslotSteps: readonly PaletteEntity[];
readonly existingCustomerInfoSteps: readonly PaletteEntity[];
readonly existingSummarySteps: readonly PaletteEntity[];
};
PaletteEntity (Enhanced with Deduplication)
Each entity includes deduplication metadata:
type PaletteEntity<TData = unknown> = {
id: string;
label: string;
description?: string;
usage_count?: number; // From deduplication (number of duplicates found)
last_used?: string; // ISO timestamp for recency-based selection
data: TData; // Full node data for copy-on-use
};
Entity Sort Options
Replaces the former filter tabs with a sort dropdown:
enum EntitySortOption {
MOST_RECENT = 'most_recent', // Default: sorted by last_used DESC
MOST_USED = 'most_used', // Sorted by usage_count DESC
ALPHABETICAL = 'alphabetical', // Sorted by label A-Z
}
Entity Deduplication
Entities are deduplicated using configurable predicate functions per node type:
// Predicate signature - returns a unique key for grouping duplicates
type DeduplicationPredicate<TData = unknown> = (
entity: PaletteEntity<TData>
) => string;
// Default predicate: deduplicate by lowercase trimmed label
const defaultLabelPredicate: DeduplicationPredicate = (entity) =>
entity.label.toLowerCase().trim();
// Node-type-specific predicates in registry
const DEDUPLICATION_PREDICATES: Partial<Record<string, DeduplicationPredicate>> = {
ZIPCODE: zipcodeStepPredicate, // By zip codes + fallback phone
QUESTION: questionPredicate, // By unique_name or title
SERVICE_ANSWER: serviceAnswerPredicate, // By title + service_id
// ... other node types
};
Deduplication Logic:
- Group entities by predicate key
- Sort each group by
last_used(most recent first) - Select the most recent entity as representative
- Set
usage_countto the group size
Key Functions:
deduplicateEntities(entities, predicate)- Core deduplicationdeduplicateEntitiesForNodeType(entities, nodeType)- Uses registrygetPredicateForNodeType(nodeType)- Returns registered or default predicate
Integration Pattern
Complete example using BookingFlowBuilderProvider:
import {
BookingFlowBuilderProvider,
useFlowCanvas,
FlowCanvas,
FlowPalette,
ValidationPanel
} from '@wrkbelt/ui-components';
function BookingFlowBuilder() {
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const [isValidationOpen, setIsValidationOpen] = useState(false);
// Map NodeType to creation functions
const handleCreateNode = useCallback((nodeType: NodeType) => {
const nodeId = `node-${Date.now()}`;
const position = { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 };
switch (nodeType) {
case NodeType.ZIPCODE:
return { id: nodeId, type: 'zipcode', position, data: createZipCodeNodeData() };
// ... handle all node types
}
}, []);
const handleSelectEntity = useCallback((nodeType: NodeType, entityId: string) => {
// Copy entity data to new node
const nodeData = fetchEntityData(entityId);
const nodeId = `node-${Date.now()}`;
const position = { x: 200, y: 200 };
return { id: nodeId, type: nodeType, position, data: nodeData };
}, []);
return (
<BookingFlowBuilderProvider
canvas={{
initialNodes: [],
initialEdges: [],
}}
palette={{
isOpen: isPaletteOpen,
onOpenChange: setIsPaletteOpen,
onCreateNode: handleCreateNode,
onSelectEntity: handleSelectEntity,
initialNodeData: paletteNodeData,
}}
validation={{
isDrawerOpen: isValidationOpen,
onDrawerOpenChange: setIsValidationOpen,
validationState: validationState,
analytics: flowAnalytics,
}}
>
<FlowBuilderContent
onOpenPalette={() => setIsPaletteOpen(true)}
onOpenValidation={() => setIsValidationOpen(true)}
/>
</BookingFlowBuilderProvider>
);
}
function FlowBuilderContent({ onOpenPalette, onOpenValidation }) {
// useFlowCanvas gives you EVERYTHING - both custom state and ReactFlow instance
const {
nodes,
fitView, // from ReactFlow
zoomIn, // from ReactFlow
canvasRef
} = useFlowCanvas();
return (
<div className="flex h-full flex-col">
<div className="flex gap-2 p-4">
<Button onClick={onOpenPalette}>Add Step</Button>
<Button onClick={onOpenValidation}>View Validation</Button>
<Button onClick={() => fitView()}>Fit View</Button>
</div>
<FlowCanvas nodeTypes={nodeTypes} className="flex-1">
<FlowPalette />
<ValidationPanel />
</FlowCanvas>
</div>
);
}
Accessing State
// ✅ Canvas state - Unified API
import { useFlowCanvas } from '@wrkbelt/ui-components';
function MyCanvasComponent() {
const {
// FlowCanvas custom state
nodes,
setNodes,
addNode,
toggleMinimap,
toggleFullScreen,
// ReactFlow instance methods (auto-composed!)
fitView,
zoomIn,
zoomOut,
getNode,
screenToFlowPosition,
} = useFlowCanvas();
const expandAll = () => {
setNodes((current) =>
current.map((node) => ({
...node,
data: { ...node.data, isCollapsed: false },
}))
);
// After expanding, fit the view
setTimeout(() => fitView(), 100);
};
}
// ✅ Palette state
import { useFlowPalette } from '@wrkbelt/ui-components';
function MyPaletteComponent() {
const { nodeData, globalSearchTerm, onCreateNode } = useFlowPalette();
// All palette components read from context - zero prop drilling
}
// ✅ Validation state
import { useValidationPanel } from '@wrkbelt/ui-components';
function MyValidationComponent() {
const { validationState, analytics, onNodeFocus } = useValidationPanel();
// Access validation issues and analytics
}
Callback Composition
GenericNode composes callbacks to sync both external consumers and internal state:
const composedOnDataChange = useCallback(
(newData) => {
// 1. External callback (Storybook, parent components)
externalOnDataChange?.(newData);
// 2. Internal state update via context
setNodes((current) =>
current.map((n) => (n.id === id ? { ...n, data: newData } : n))
);
},
[id, setNodes, externalOnDataChange]
);
Performance Optimization
Critical Performance Patterns
The architecture implements several critical optimizations to maintain 60fps interactions with 50+ nodes:
1. Memoized Context Values
CRITICAL: Both context providers memoize their values to prevent cascade re-renders.
// ✅ CORRECT - FlowCanvasProvider
const contextValue: FlowCanvasContextValue = useMemo(
() => ({
nodes,
edges,
onNodesChange,
// ... all properties
}),
[nodes, edges, onNodesChange, /* all dependencies */]
);
// ❌ WRONG - Creates new object every render
const contextValue: FlowCanvasContextValue = {
nodes,
edges,
// ... causes ALL consumers to re-render
};
Impact: Without memoization, every state change (pan, zoom, drawer toggle) causes ALL nodes to re-render (50-100ms per interaction).
2. Singleton Pattern for Expensive Computations
IconCombobox uses a module-scoped cache to prevent regenerating 3000+ icon options:
// icon-combobox.utils.ts
let cachedIconOptions: IconOption[] | null = null;
export function getAllIconOptions(): IconOption[] {
if (cachedIconOptions) {
return cachedIconOptions; // ✅ Instant return after first generation
}
// Generate once, cache forever
const iconNames = Object.keys(icons) as LucideIconName[];
cachedIconOptions = iconNames.map(/* transform */);
return cachedIconOptions;
}
Impact: Reduces 100-200ms per IconCombobox mount to near-zero after first load.
3. Component Memoization
All presentational components use memo() to prevent unnecessary re-renders:
// ✅ CORRECT - Memoized components
export const DynamicIcon = memo(function DynamicIcon({ name, ...props }) {
const Icon = getIconByName(name);
return <Icon {...props} />;
});
export const EntityCard = memo<EntityCardProps>(function EntityCard({ entity, onSelect }) {
// Only re-renders when entity or onSelect changes
});
Impact: Critical for rendering 3000+ icons in dropdown without performance degradation.
4. Loop Prevention in Bidirectional Sync
Nodes use refs to prevent infinite loops when syncing form state:
export function useMyNode() {
const isInternalUpdateRef = useRef(false);
const prevDataRef = useRef<string>('');
// Form → Parent
useEffect(() => {
const subscription = form.watch((values) => {
isInternalUpdateRef.current = true; // ✅ Mark as internal
updateData(values);
});
return () => subscription.unsubscribe();
}, [form, updateData]);
// Parent → Form
useEffect(() => {
const currentDataStr = JSON.stringify(data);
if (currentDataStr === prevDataRef.current) return; // ✅ Skip if unchanged
if (isInternalUpdateRef.current) {
isInternalUpdateRef.current = false; // ✅ Skip self-caused updates
prevDataRef.current = currentDataStr;
return;
}
prevDataRef.current = currentDataStr;
form.reset(data);
}, [data, form]);
}
Memoization Checklist
- Wrap inner component in
memo() - Wrap exported component in
memo() - Use
useMemo()for computed values - Use
useCallback()for event handlers - Extract form.watch() values to variables
- Implement conditional rendering for expensive sections
- Memoize context values with complete dependency arrays
- Use singleton pattern for expensive static computations
- Implement loop prevention in bidirectional sync
Component Memoization Example
// Component memoization
const MyNodeContent = memo(function MyNodeContent() {
// Computed value memoization
const headerConfig = useMemo(() => ({ /* config */ }), [title]);
// Callback memoization
const handleAdd = useCallback(() => { /* logic */ }, [deps]);
// Extract watched values
const title = form.watch('title');
return (/* JSX */);
});
export const MyNode = memo<GenericNodeProps<MyNodeData>>(
function MyNode(props) {
return (
<GenericNode {...props} data={mergedData}>
<MyNodeContent />
</GenericNode>
);
}
);
Performance Metrics
| Optimization | Before | After | Improvement |
|---|---|---|---|
| Context value memoization | 50-100ms per interaction | <5ms | 95% |
| Icon options singleton | 100-200ms per mount | <5ms | 97% |
| DynamicIcon memoization | 50-100ms dropdown render | <10ms | 90% |
| Total worst case | 300-500ms | <25ms | 92-95% |
Best Practices
✅ Do This
Provider Setup:
- ✅ Use
BookingFlowBuilderProviderto compose all contexts - ✅ Only include optional providers (palette, validation) when needed
- ✅ Use
useFlowCanvas()for unified access to both custom state and ReactFlow methods - ✅ Use
useFlowPalette()for palette state access - ✅ Use
useValidationPanel()for validation state access
Context & State:
- ✅ Use the unified
useFlowCanvas()hook - it composes everything - ✅ Leverage ReactFlow instance methods through
useFlowCanvas()(fitView, zoomIn, etc.) - ✅ Memoize all context values with complete dependency arrays
- ✅ Implement loop prevention in bidirectional sync hooks
- ✅ Use singleton pattern for expensive static computations
TypeScript:
- ✅ ALWAYS use
typeoverinterface - ✅ Use
readonlyproperties for immutability - ✅ Export types for public APIs
- ✅ Use Zod for runtime validation schemas
Component Design:
- ✅ Memoize all components with
memo() - ✅ Use
form.watch()for immediate sync - ✅ Follow compound component pattern (GenericNode., FlowControls.)
- ✅ Use nested data structures (
{ myNodeData: {...} }) - ✅ Make
step_typereadonly - ✅ Use portal containers for popovers in fullscreen mode
Controls & UI:
- ✅ Use built-in
FlowControlscomponent - ✅ Compose individual control components when needed
- ✅ Use
BookingFlowMinimapfor themed minimap - ✅ Pass
canvasRefto popoverportalContainerfor fullscreen support
Performance:
- ✅ Extract form.watch() values to variables
- ✅ Use
useMemo()for computed values - ✅ Use
useCallback()for event handlers - ✅ Implement conditional rendering for expensive sections
- ✅ Cache expensive computations at module scope when safe
Validation & Analytics:
- ✅ Provide analytics data to ValidationPanel
- ✅ Include both click metrics and service selection metrics
- ✅ Use data-focused presentation (no value judgments)
- ✅ Handle node focus callbacks for validation UX
❌ Don't Do This
Provider Setup:
- ❌ Don't manually nest FlowCanvasProvider, FlowPaletteProvider, etc.
- ❌ Don't use
useReactFlow()directly - defeats unified API - ❌ Don't bypass BookingFlowBuilderProvider
- ❌ Don't include unused optional providers
Context & State:
- ❌ Don't use both
useReactFlow()anduseFlowCanvas()- just useuseFlowCanvas() - ❌ Don't bypass context providers
- ❌ Don't skip context value memoization
- ❌ Don't prop drill through multiple levels
TypeScript:
- ❌ NEVER use
interface- always usetype - ❌ Don't skip readonly modifiers
- ❌ Don't use
anytypes
Component Design:
- ❌ Don't add save buttons to nodes
- ❌ Don't maintain local state for controlled values
- ❌ Don't create monolithic components
- ❌ Don't mutate step_type after creation
- ❌ Don't use manual validation
- ❌ Don't render popovers without portal containers in fullscreen mode
Performance:
- ❌ Don't skip memoization
- ❌ Don't regenerate expensive static data on every render
- ❌ Don't forget loop prevention in bidirectional sync
- ❌ Don't create new callback objects in render
Analytics:
- ❌ Don't add subjective labels ("Good", "Bad", "Optimized") to metrics
- ❌ Don't show metrics without proper data structure
Debugging Tips
State Not Updating?
- Check if using
useFlowCanvas()(notuseReactFlow()) - Verify callback composition in
GenericNodeBase - Check if external
onDataChangeis called
Infinite Loops?
- Check
isInternalUpdateReflogic - Verify deep comparison with
prevDataRef - Ensure form.watch subscription cleanup
Performance Issues?
Diagnostic Steps:
- Open React DevTools Profiler
- Record interaction (drag, pan, open palette)
- Analyze flame graph for cascade re-renders
- Check component render counts
Common Issues:
- Missing context value memoization → All consumers re-render
- Missing component
memo()→ Unnecessary re-renders - Regenerating expensive data (e.g., icon options) → Slow mounts
- Missing loop prevention → Infinite update cycles
Quick Fixes:
- Add
useMemo()to context values with complete dependency arrays - Wrap components in
memo() - Use singleton pattern for static data
- Implement
isInternalUpdateRefpattern
Palette Not Showing?
- Verify
isOpenprop is controlled externally via BookingFlowBuilderProvider - Check
portalContainerref is valid - Ensure FlowPaletteProvider wraps FlowPalette (auto-handled by BookingFlowBuilderProvider)
- Verify SideDrawer has correct z-index
Popovers Not Visible in Fullscreen?
- Ensure controls use
portalContainer={canvasRef.current || undefined} - Check that
canvasRefis fromuseFlowCanvas() - Verify
PopoverContenthasportalContainerprop - Confirm fullscreen container has
z-50class
Example Fix:
export const MyControl: React.FC = () => {
const { canvasRef } = useFlowCanvas();
return (
<Popover>
<PopoverContent
portalContainer={canvasRef.current || undefined} // ← Critical!
>
{/* Content now renders inside canvas, visible in fullscreen */}
</PopoverContent>
</Popover>
);
};
Minimap Not Toggling?
- Verify
showMinimapstate exists in FlowCanvasContext - Check
toggleMinimapis called from MinimapControl - Ensure
<BookingFlowMinimap />checksshowMinimapcondition - Confirm minimap is a direct child of
<ReactFlow>, not nested in Panel
Analytics Not Showing?
- Verify
analyticsprop is passed to ValidationPanelProvider - Check analytics data structure matches
FlowAnalyticstype - Ensure
clickMetricsis always provided (required) - Verify
serviceSelectionMetricsis optional and typed correctly
useFlowCanvas() Returns Undefined Methods?
- Confirm component is inside FlowCanvasProvider
- Verify ReactFlowProvider is wrapped by FlowCanvasProvider (auto-handled)
- Check that FlowCanvasProvider is rendering
<ReactFlowProvider>internally - Ensure you're not calling hook outside of React tree
Common Issue:
// ❌ WRONG - Called outside provider
const { fitView } = useFlowCanvas(); // Error!
function App() {
return <BookingFlowBuilderProvider>...</BookingFlowBuilderProvider>;
}
// ✅ CORRECT - Called inside provider
function App() {
return (
<BookingFlowBuilderProvider canvas={{...}}>
<MyComponent /> {/* useFlowCanvas works here */}
</BookingFlowBuilderProvider>
);
}