Skip to main content

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's useReactFlow() into a single hook
  • FlowCanvasProvider wraps ReactFlowProvider - prevents double-wrapping issues
  • All ReactFlow instance methods (fitView, zoomIn, getNode, etc.) are available through useFlowCanvas()
// ✅ 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):

  1. FlowCanvasProvider (required) - Includes ReactFlowProvider, manages nodes/edges
  2. FlowPaletteProvider (optional) - Node palette drawer and selection
  3. 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

PropTypeRequiredDescription
canvasBookingFlowCanvasConfigCanvas/Flow configuration
paletteBookingFlowPaletteConfigNode palette configuration
validationBookingFlowValidationConfigValidation panel configuration

Built-In Components

FlowControls

Compound component that provides canvas control functionality. Automatically included in FlowCanvas.

Sub-Components

ComponentDescriptionType
FullscreenControlToggle fullscreen modeButton
MinimapControlToggle minimap visibilityButton
AutoLayoutControlAuto-layout configurationPopover
NodeExpansionControlExpand/collapse all nodesPopover

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

PropertyValueDescription
bgColor#E8EDF5Light blue background
nodeColor#F5F6F8Off-white for nodes
nodeStrokeColor#CBD5E1Slate-300 for borders
positiontop-leftDefault 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

NodePurposeKey Configuration
ZipCodeNodeService area validationZip list, error messaging
ServiceSelectionNodeService/question selectionContains service selection graph
AdditionalDetailsNodeOptional detailsField configuration
TimeslotSelectionNodeAppointment schedulingAvailability rules, buffers
CustomerInfoNodeContact informationRequired/optional toggles
SummaryNodeConfirmation screenSuccess messaging

Service Selection Sub-Nodes

NodePurposeConnections
QuestionNodeBranch pointOutputs to answers
AnswerCategoryNodeAnswer groupingGroups related answers
TextAnswerNodeSimple text answerTo next question or terminate
ServiceAnswerNodeService-linked answerTo 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:

  1. Group entities by predicate key
  2. Sort each group by last_used (most recent first)
  3. Select the most recent entity as representative
  4. Set usage_count to the group size

Key Functions:

  • deduplicateEntities(entities, predicate) - Core deduplication
  • deduplicateEntitiesForNodeType(entities, nodeType) - Uses registry
  • getPredicateForNodeType(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

OptimizationBeforeAfterImprovement
Context value memoization50-100ms per interaction<5ms95%
Icon options singleton100-200ms per mount<5ms97%
DynamicIcon memoization50-100ms dropdown render<10ms90%
Total worst case300-500ms<25ms92-95%

Best Practices

✅ Do This

Provider Setup:

  • ✅ Use BookingFlowBuilderProvider to 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 type over interface
  • ✅ Use readonly properties 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_type readonly
  • ✅ Use portal containers for popovers in fullscreen mode

Controls & UI:

  • ✅ Use built-in FlowControls component
  • ✅ Compose individual control components when needed
  • ✅ Use BookingFlowMinimap for themed minimap
  • ✅ Pass canvasRef to popover portalContainer for 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() and useFlowCanvas() - just use useFlowCanvas()
  • ❌ Don't bypass context providers
  • ❌ Don't skip context value memoization
  • ❌ Don't prop drill through multiple levels

TypeScript:

  • NEVER use interface - always use type
  • ❌ Don't skip readonly modifiers
  • ❌ Don't use any types

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?

  1. Check if using useFlowCanvas() (not useReactFlow())
  2. Verify callback composition in GenericNodeBase
  3. Check if external onDataChange is called

Infinite Loops?

  1. Check isInternalUpdateRef logic
  2. Verify deep comparison with prevDataRef
  3. Ensure form.watch subscription cleanup

Performance Issues?

Diagnostic Steps:

  1. Open React DevTools Profiler
  2. Record interaction (drag, pan, open palette)
  3. Analyze flame graph for cascade re-renders
  4. Check component render counts

Common Issues:

  1. Missing context value memoization → All consumers re-render
  2. Missing component memo() → Unnecessary re-renders
  3. Regenerating expensive data (e.g., icon options) → Slow mounts
  4. 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 isInternalUpdateRef pattern

Palette Not Showing?

  1. Verify isOpen prop is controlled externally via BookingFlowBuilderProvider
  2. Check portalContainer ref is valid
  3. Ensure FlowPaletteProvider wraps FlowPalette (auto-handled by BookingFlowBuilderProvider)
  4. Verify SideDrawer has correct z-index

Popovers Not Visible in Fullscreen?

  1. Ensure controls use portalContainer={canvasRef.current || undefined}
  2. Check that canvasRef is from useFlowCanvas()
  3. Verify PopoverContent has portalContainer prop
  4. Confirm fullscreen container has z-50 class

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?

  1. Verify showMinimap state exists in FlowCanvasContext
  2. Check toggleMinimap is called from MinimapControl
  3. Ensure <BookingFlowMinimap /> checks showMinimap condition
  4. Confirm minimap is a direct child of <ReactFlow>, not nested in Panel

Analytics Not Showing?

  1. Verify analytics prop is passed to ValidationPanelProvider
  2. Check analytics data structure matches FlowAnalytics type
  3. Ensure clickMetrics is always provided (required)
  4. Verify serviceSelectionMetrics is optional and typed correctly

useFlowCanvas() Returns Undefined Methods?

  1. Confirm component is inside FlowCanvasProvider
  2. Verify ReactFlowProvider is wrapped by FlowCanvasProvider (auto-handled)
  3. Check that FlowCanvasProvider is rendering <ReactFlowProvider> internally
  4. 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>
);
}