Chapter 17: Composed UI Patterns
In the previous chapter, we explored atomic UI behaviors—Dialog, Disclosure, Form, List Selection, Roving Focus. These behaviors handle individual interaction patterns: opening a dialog, expanding a section, navigating a list with keyboard arrows. They're the building blocks of user interfaces.
But real applications need more than atomic behaviors. They need complete interaction patterns that combine multiple behaviors into cohesive workflows: a Master-Detail view that synchronizes list selection with detail display, a Wizard that guides users through multi-step forms with validation, a Command Palette that combines dialog, search, and keyboard navigation, a Modal system that manages stacking and focus across multiple overlays.
These are composed UI patterns—higher-level patterns built by combining atomic behaviors. Just as atomic behaviors separate interaction logic from presentation, composed patterns separate workflow orchestration from implementation details. They provide complete, reusable solutions to common UI challenges while remaining framework-agnostic and fully customizable.
This chapter explores composed UI patterns in depth. We'll examine why pattern composition matters for MVVM architecture, understand how atomic behaviors combine into complete patterns, then show real implementations from the Web Loom monorepo. We'll use ui-patterns as a concrete example, but the composition techniques we discuss apply to any UI library—or your own custom patterns.
The goal isn't to memorize specific patterns. It's to understand the composition principles so you can build your own patterns when needed.
Understanding Pattern Composition
Let's start with a fundamental insight: Complex UI patterns are compositions of simpler behaviors.
Consider a Master-Detail interface—the split-view pattern used in email clients, file explorers, and admin panels. On the surface, it seems like a single, monolithic pattern. But break it down:
- A list of items (master view)
- Selection behavior to track which item is active
- A detail view that displays the selected item
- Synchronization between selection and detail display
- Event communication to notify other components of selection changes
Each piece is an atomic behavior. The Master-Detail pattern composes these behaviors into a cohesive workflow.
This composition approach has profound implications:
Implication 1: Patterns are reusable. Once you've composed a Master-Detail pattern, you can use it for sensor lists, greenhouse lists, alert lists—any domain that needs split-view navigation.
Implication 2: Patterns are customizable. Because patterns compose atomic behaviors, you can swap behaviors or adjust configuration without rewriting the pattern. Need multi-select instead of single-select? Change the list selection mode. Need to validate before showing details? Add validation logic.
Implication 3: Patterns are testable. Test atomic behaviors in isolation, then test pattern composition separately. This separation makes testing more focused and maintainable.
Implication 4: Patterns are framework-agnostic. If atomic behaviors work across frameworks, composed patterns do too. Write the pattern once, use it in React, Vue, Angular, or vanilla JS.
Let's see this in practice with real code from the Web Loom monorepo.
Master-Detail Pattern: Synchronized Split Views
The Master-Detail pattern is one of the most common UI patterns in data-driven applications. It displays a list of items (master) alongside detailed information about the selected item (detail). Email clients use it for inbox and message view. File explorers use it for folder contents and file preview. Admin panels use it for entity lists and edit forms.
Here's how the pattern composes atomic behaviors:
// From packages/ui-patterns/src/patterns/master-detail.ts
export function createMasterDetail<T = any>(options: MasterDetailOptions<T>): MasterDetailBehavior<T> {
const items = options.items || [];
// Create event bus for pattern-level events
const eventBus = createEventBus<MasterDetailEvents<T>>();
// Create a map from item ID to item for quick lookup
const itemMap = new Map<string, T>();
items.forEach((item) => {
const id = options.getId(item);
itemMap.set(id, item);
});
// Compose: List selection behavior for managing selection
const listSelection: ListSelectionBehavior = createListSelection({
items: items.map(options.getId),
mode: 'single', // Master-detail typically uses single selection
onSelectionChange: (selectedIds) => {
// Sync selection with master-detail state
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
const selectedItem = selectedId ? itemMap.get(selectedId) || null : null;
store.actions.updateSelectedItem(selectedItem);
if (options.onSelectionChange) {
options.onSelectionChange(selectedItem);
}
// Emit pattern-level event
if (selectedItem) {
eventBus.emit('item:selected', selectedItem);
}
},
});
// Compose: Store for master-detail state
const store = createStore<MasterDetailState<T>, MasterDetailActions<T>>({
items,
selectedItem: null,
detailView: options.initialDetailView || 'default',
}, (set) => ({
selectItem: (item: T) => {
const id = options.getId(item);
listSelection.actions.select(id);
},
clearSelection: () => {
listSelection.actions.clearSelection();
set((state) => ({ ...state, selectedItem: null }));
eventBus.emit('selection:cleared');
},
setDetailView: (view: string) => {
set((state) => ({ ...state, detailView: view }));
},
}));
return {
getState: store.getState,
subscribe: store.subscribe,
actions: store.actions,
eventBus,
destroy: () => {
listSelection.destroy();
store.destroy();
},
};
}Notice the composition strategy:
-
List Selection Behavior (
createListSelectionfromui-core) handles the selection logic—tracking which item is selected, supporting keyboard navigation, managing selection state. -
Store (
createStorefromstore-core) manages the master-detail state—the list of items, the selected item, the current detail view. -
Event Bus (
createEventBusfromevent-bus-core) enables event-driven communication—emittingitem:selectedandselection:clearedevents that other components can listen to. -
Synchronization Logic connects these pieces—when list selection changes, update the store's selected item and emit events.
The pattern doesn't reimplement selection logic or state management. It composes existing behaviors and adds synchronization glue. This is the essence of pattern composition.
Using Master-Detail in GreenWatch
Let's see how this pattern works in the greenhouse monitoring system. We'll build a sensor list with detail view:
// Sensor list with detail view
import { createMasterDetail } from '@web-loom/ui-patterns';
import type { Sensor } from '@web-loom/models';
interface SensorListProps {
sensors: Sensor[];
}
function SensorMasterDetail({ sensors }: SensorListProps) {
// Create master-detail pattern
const masterDetail = createMasterDetail<Sensor>({
items: sensors,
getId: (sensor) => sensor.id,
onSelectionChange: (sensor) => {
console.log('Selected sensor:', sensor?.name);
},
});
// Subscribe to state changes
const [state, setState] = useState(masterDetail.getState());
useEffect(() => {
return masterDetail.subscribe(setState);
}, []);
// Listen to events
useEffect(() => {
const unsubscribe = masterDetail.eventBus.on('item:selected', (sensor) => {
// Could trigger analytics, load additional data, etc.
console.log('Sensor selected event:', sensor.id);
});
return unsubscribe;
}, []);
return (
<div className="master-detail-layout">
{/* Master: Sensor list */}
<div className="master-pane">
<h2>Sensors</h2>
<ul>
{state.items.map((sensor) => (
<li
key={sensor.id}
className={state.selectedItem?.id === sensor.id ? 'selected' : ''}
onClick={() => masterDetail.actions.selectItem(sensor)}
>
{sensor.name}
<span className="sensor-type">{sensor.type}</span>
</li>
))}
</ul>
</div>
{/* Detail: Sensor information */}
<div className="detail-pane">
{state.selectedItem ? (
<SensorDetail sensor={state.selectedItem} />
) : (
<div className="empty-state">
Select a sensor to view details
</div>
)}
</div>
</div>
);
}The pattern handles all the complexity:
- Selection tracking: Which sensor is currently selected
- State synchronization: Keeping master and detail views in sync
- Event communication: Notifying other components of selection changes
- Lifecycle management: Cleaning up subscriptions when unmounted
You focus on presentation—how the list looks, how the detail view displays sensor information. The pattern handles the interaction logic.
And because the pattern is framework-agnostic, the same code works in Vue, Angular, or vanilla JS. Only the view layer changes; the pattern logic remains identical.
Wizard Pattern: Multi-Step Flows with Validation
Wizards guide users through multi-step processes: onboarding flows, checkout processes, configuration wizards, form builders. They're everywhere in modern applications, yet implementing them correctly is surprisingly complex.
A robust wizard needs:
- Step management: Tracking current step, completed steps, step order
- Navigation: Moving forward, backward, jumping to specific steps
- Validation: Ensuring each step is valid before proceeding
- Branching logic: Conditional navigation based on user choices
- Data accumulation: Collecting data across all steps
- Completion handling: Final submission when all steps are complete
The Wizard pattern composes these concerns into a single, reusable solution:
// From packages/ui-patterns/src/patterns/wizard.ts
export function createWizard<T extends Record<string, any> = Record<string, any>>(
options: WizardOptions<T>,
): WizardBehavior<T> {
const steps = options.steps;
const initialStepIndex = options.initialStepIndex ?? 0;
const initialData = (options.initialData ?? {}) as T;
// Compose: Form behavior for validation
const formBehavior: FormBehavior<T> = createFormBehavior<T>({
initialValues: initialData,
validateOnChange: false,
validateOnBlur: false,
});
// Compose: Store for wizard state
const store = createStore<WizardState<T>, WizardActions>({
steps,
currentStepIndex: initialStepIndex,
completedSteps: [],
canProceed: true,
data: initialData,
}, (set, get, actions) => ({
goToNextStep: async () => {
const state = get();
const currentStep = state.steps[state.currentStepIndex];
// Validate current step
if (currentStep.validate) {
const error = await Promise.resolve(currentStep.validate(state.data));
if (error) {
set((state) => ({ ...state, canProceed: false }));
return false;
}
}
// Mark step as completed
const completedSteps = [...state.completedSteps];
if (!completedSteps.includes(state.currentStepIndex)) {
completedSteps.push(state.currentStepIndex);
}
// Determine next step (supports branching)
let nextStepIndex: number;
if (currentStep.getNextStep) {
const nextStepId = currentStep.getNextStep(state.data);
if (nextStepId) {
const foundIndex = state.steps.findIndex((s) => s.id === nextStepId);
nextStepIndex = foundIndex !== -1 ? foundIndex : state.currentStepIndex + 1;
} else {
nextStepIndex = state.currentStepIndex + 1;
}
} else {
nextStepIndex = state.currentStepIndex + 1;
}
if (nextStepIndex >= state.steps.length) {
set((state) => ({ ...state, completedSteps, canProceed: false }));
return false;
}
set((state) => ({
...state,
currentStepIndex: nextStepIndex,
completedSteps,
canProceed: true,
}));
if (options.onStepChange) {
options.onStepChange(nextStepIndex, state.steps[nextStepIndex]);
}
return true;
},
goToPreviousStep: () => {
const state = get();
if (state.currentStepIndex > 0) {
const newStepIndex = state.currentStepIndex - 1;
set((state) => ({ ...state, currentStepIndex: newStepIndex, canProceed: true }));
if (options.onStepChange) {
options.onStepChange(newStepIndex, state.steps[newStepIndex]);
}
}
},
setStepData: (data: any) => {
set((state) => {
const newData = { ...state.data, ...data };
if (options.onDataChange) {
options.onDataChange(newData);
}
return { ...state, data: newData };
});
},
completeWizard: async () => {
const state = get();
// Validate all steps
for (let i = 0; i < state.steps.length; i++) {
const step = state.steps[i];
if (step.validate) {
const error = await Promise.resolve(step.validate(state.data));
if (error) {
actions.goToStep(i);
return;
}
}
}
if (options.onComplete) {
await Promise.resolve(options.onComplete(state.data));
}
},
}));
return {
getState: store.getState,
subscribe: store.subscribe,
actions: store.actions,
destroy: () => {
formBehavior.destroy();
store.destroy();
},
};
}The composition strategy here is different from Master-Detail:
-
Form Behavior (
createFormBehaviorfromui-core) handles validation logic—though in this implementation, validation is primarily done through step-level validate functions. -
Store manages wizard state—current step, completed steps, accumulated data, navigation state.
-
Branching Logic enables conditional navigation—the
getNextStepfunction on each step can return a different step ID based on user choices. -
Validation Pipeline ensures data quality—each step can have a validation function that must pass before proceeding.
The pattern doesn't just compose behaviors; it adds workflow orchestration—the logic that coordinates step transitions, validates data, and manages the overall wizard lifecycle.
Using Wizard for Greenhouse Setup
Let's use the Wizard pattern for a greenhouse setup flow with branching logic:
// Greenhouse setup wizard with branching
import { createWizard } from '@web-loom/ui-patterns';
interface GreenhouseSetupData {
name?: string;
type?: 'indoor' | 'outdoor';
size?: number;
hasAutomation?: boolean;
automationLevel?: 'basic' | 'advanced';
sensorCount?: number;
}
function GreenhouseSetupWizard() {
const wizard = createWizard<GreenhouseSetupData>({
steps: [
{
id: 'basic-info',
label: 'Basic Information',
validate: (data) => {
if (!data.name) return 'Greenhouse name is required';
if (!data.type) return 'Please select a greenhouse type';
return null;
},
},
{
id: 'size',
label: 'Size Configuration',
validate: (data) => {
if (!data.size || data.size <= 0) return 'Size must be greater than 0';
return null;
},
},
{
id: 'automation',
label: 'Automation Setup',
validate: (data) => {
if (data.hasAutomation === undefined) {
return 'Please indicate if you want automation';
}
return null;
},
// Branching: Skip automation details if user doesn't want automation
getNextStep: (data) => {
return data.hasAutomation ? 'automation-details' : 'sensors';
},
},
{
id: 'automation-details',
label: 'Automation Details',
validate: (data) => {
if (!data.automationLevel) return 'Please select automation level';
return null;
},
},
{
id: 'sensors',
label: 'Sensor Configuration',
validate: (data) => {
if (!data.sensorCount || data.sensorCount < 1) {
return 'At least one sensor is required';
}
return null;
},
},
],
initialData: {},
onComplete: async (data) => {
console.log('Creating greenhouse with data:', data);
// Submit to API
await createGreenhouse(data);
},
onStepChange: (stepIndex, step) => {
console.log(`Moved to step ${stepIndex}: ${step.label}`);
},
});
const [state, setState] = useState(wizard.getState());
useEffect(() => {
return wizard.subscribe(setState);
}, []);
const currentStep = state.steps[state.currentStepIndex];
return (
<div className="wizard-container">
{/* Progress indicator */}
<div className="wizard-progress">
{state.steps.map((step, index) => (
<div
key={step.id}
className={`step ${index === state.currentStepIndex ? 'active' : ''} ${
state.completedSteps.includes(index) ? 'completed' : ''
}`}
>
{step.label}
</div>
))}
</div>
{/* Current step content */}
<div className="wizard-content">
<h2>{currentStep.label}</h2>
{renderStepContent(currentStep.id, state.data, wizard.actions.setStepData)}
</div>
{/* Navigation */}
<div className="wizard-navigation">
<button
onClick={() => wizard.actions.goToPreviousStep()}
disabled={state.currentStepIndex === 0}
>
Previous
</button>
{state.currentStepIndex < state.steps.length - 1 ? (
<button
onClick={async () => {
const success = await wizard.actions.goToNextStep();
if (!success) {
alert('Please complete this step before proceeding');
}
}}
>
Next
</button>
) : (
<button onClick={() => wizard.actions.completeWizard()}>
Complete Setup
</button>
)}
</div>
</div>
);
}The wizard handles complex workflow logic:
- Validation: Each step validates before allowing navigation
- Branching: The automation step conditionally skips automation-details based on user choice
- Progress tracking: Completed steps are tracked and displayed
- Data accumulation: All step data is collected into a single object
- Completion: Final submission only happens after all steps are valid
This is workflow orchestration at its finest—complex logic encapsulated in a reusable pattern.
Modal Pattern: Stacked Dialog Management
Modals (also called dialogs or overlays) are ubiquitous in modern applications. But managing multiple modals is surprisingly complex:
- Stacking: Multiple modals can be open simultaneously, each with its own priority
- Focus management: Focus should trap within the topmost modal
- Escape handling: Escape key should close the topmost modal (if configured)
- Backdrop clicks: Clicking outside should close the modal (if configured)
- Lifecycle: Opening and closing modals should trigger callbacks
The Modal pattern composes Dialog behavior with stacking logic:
// From packages/ui-patterns/src/patterns/modal.ts (simplified)
export function createModal(options?: ModalOptions): ModalBehavior {
const eventBus = createEventBus<ModalEvents>();
const dialogBehaviors = new Map<string, DialogBehavior>();
const store = createStore<ModalState, ModalActions>({
stack: [],
topModalId: null,
}, (set, get) => ({
openModalWithConfig: (config: OpenModalConfig) => {
const { id, content, priority = 0, closeOnEscape = false, closeOnBackdropClick = false } = config;
// Create dialog behavior for this modal
const dialogBehavior = createDialogBehavior({ id });
dialogBehaviors.set(id, dialogBehavior);
dialogBehavior.actions.open(content);
// Add to stack
const modal: Modal = { id, content, priority, closeOnEscape, closeOnBackdropClick };
const newStack = [...get().stack, modal];
const sortedStack = sortStack(newStack); // Sort by priority
const topModalId = sortedStack[0]?.id || null;
set(() => ({ stack: sortedStack, topModalId }));
eventBus.emit('modal:opened', modal);
if (options?.onModalOpened) options.onModalOpened(modal);
},
handleEscapeKey: () => {
const state = get();
if (!state.topModalId) return;
const topModal = state.stack.find((m) => m.id === state.topModalId);
if (!topModal) return;
eventBus.emit('modal:escape-pressed', topModal.id);
if (topModal.closeOnEscape) {
store.actions.closeModal(topModal.id);
}
},
handleBackdropClick: (id: string) => {
const modal = get().stack.find((m) => m.id === id);
if (!modal) return;
eventBus.emit('modal:backdrop-clicked', id);
if (modal.closeOnBackdropClick) {
store.actions.closeModal(id);
}
},
closeModal: (id: string) => {
const dialogBehavior = dialogBehaviors.get(id);
if (dialogBehavior) {
dialogBehavior.actions.close();
dialogBehavior.destroy();
dialogBehaviors.delete(id);
}
const newStack = get().stack.filter((m) => m.id !== id);
const topModalId = newStack[0]?.id || null;
set(() => ({ stack: newStack, topModalId }));
eventBus.emit('modal:closed', id);
if (options?.onModalClosed) options.onModalClosed(id);
},
}));
return {
getState: store.getState,
subscribe: store.subscribe,
actions: store.actions,
eventBus,
destroy: () => {
dialogBehaviors.forEach((behavior) => behavior.destroy());
dialogBehaviors.clear();
store.destroy();
},
};
}The composition here is sophisticated:
- Multiple Dialog Behaviors: Each modal gets its own Dialog behavior instance, managed in a Map
- Stack Management: Modals are stored in a stack, sorted by priority
- Event Bus: Pattern-level events (
modal:opened,modal:escape-pressed,modal:backdrop-clicked) enable rich interactions - Configuration: Each modal can have its own
closeOnEscapeandcloseOnBackdropClicksettings
This pattern shows how composition can manage multiple instances of the same behavior, coordinating them through higher-level logic.
Using Modal for Confirmation Dialogs
Let's use the Modal pattern for confirmation dialogs in the greenhouse system:
// Confirmation dialog system
import { createModal } from '@web-loom/ui-patterns';
function useConfirmationModal() {
const modal = createModal({
onModalOpened: (modal) => {
console.log('Confirmation modal opened:', modal.id);
},
});
const confirm = (message: string): Promise<boolean> => {
return new Promise((resolve) => {
modal.actions.openModalWithConfig({
id: 'confirmation',
content: {
message,
onConfirm: () => {
modal.actions.closeModal('confirmation');
resolve(true);
},
onCancel: () => {
modal.actions.closeModal('confirmation');
resolve(false);
},
},
priority: 100, // High priority for confirmations
closeOnEscape: true,
closeOnBackdropClick: false, // Don't close on backdrop click for confirmations
});
});
};
return { modal, confirm };
}
// Usage in a component
function SensorDeleteButton({ sensor }: { sensor: Sensor }) {
const { modal, confirm } = useConfirmationModal();
const [state, setState] = useState(modal.getState());
useEffect(() => {
return modal.subscribe(setState);
}, []);
const handleDelete = async () => {
const confirmed = await confirm(
`Are you sure you want to delete sensor "${sensor.name}"? This action cannot be undone.`
);
if (confirmed) {
await deleteSensor(sensor.id);
console.log('Sensor deleted');
}
};
return (
<>
<button onClick={handleDelete} className="btn-danger">
Delete Sensor
</button>
{/* Render modal if open */}
{state.topModalId === 'confirmation' && (
<ConfirmationDialog
message={state.stack[0].content.message}
onConfirm={state.stack[0].content.onConfirm}
onCancel={state.stack[0].content.onCancel}
/>
)}
</>
);
}The Modal pattern handles all the complexity of modal management, letting you focus on the confirmation logic and UI.
Command Palette Pattern: Keyboard-Driven Actions
Command palettes have become a standard feature in modern applications—think VS Code's Command Palette (Cmd+Shift+P), Slack's quick switcher (Cmd+K), or GitHub's command bar. They provide keyboard-driven access to application features through fuzzy search.
Building a command palette requires:
- Dialog behavior: Opening and closing the palette
- Search/filtering: Fuzzy matching commands against user query
- Keyboard navigation: Arrow keys to navigate filtered results
- Command execution: Running the selected command
- Command registry: Managing available commands
The Command Palette pattern composes these concerns:
// From packages/ui-patterns/src/patterns/command-palette.ts (simplified)
export function createCommandPalette(options?: CommandPaletteOptions): CommandPaletteBehavior {
const initialCommands = options?.commands || [];
// Compose: Dialog behavior for open/close
const dialogBehavior = createDialogBehavior({
id: 'command-palette',
onOpen: options?.onOpen,
onClose: options?.onClose,
});
// Compose: Roving focus for keyboard navigation
const rovingFocusBehavior = createRovingFocus({
items: initialCommands.map((cmd) => cmd.id),
orientation: 'vertical',
wrap: true,
initialIndex: 0,
});
const store = createStore<CommandPaletteState, CommandPaletteActions>({
isOpen: false,
query: '',
commands: initialCommands,
filteredCommands: initialCommands,
selectedIndex: 0,
}, (set, get, actions) => ({
open: () => {
dialogBehavior.actions.open({});
set((state) => ({
...state,
isOpen: true,
query: '',
filteredCommands: state.commands,
selectedIndex: 0,
}));
rovingFocusBehavior.actions.setItems(get().filteredCommands.map((cmd) => cmd.id));
rovingFocusBehavior.actions.moveTo(0);
},
setQuery: (query: string) => {
const state = get();
const filteredCommands = filterCommands(state.commands, query); // Fuzzy search
set((state) => ({
...state,
query,
filteredCommands,
selectedIndex: 0,
}));
rovingFocusBehavior.actions.setItems(filteredCommands.map((cmd) => cmd.id));
rovingFocusBehavior.actions.moveTo(0);
},
selectNext: () => {
rovingFocusBehavior.actions.moveNext();
},
selectPrevious: () => {
rovingFocusBehavior.actions.movePrevious();
},
executeSelected: async () => {
const state = get();
if (state.filteredCommands.length === 0) return;
const selectedCommand = state.filteredCommands[state.selectedIndex];
await actions.executeCommand(selectedCommand.id);
},
executeCommand: async (commandId: string) => {
const command = get().commands.find((cmd) => cmd.id === commandId);
if (!command) return;
actions.close();
await Promise.resolve(command.action());
if (options?.onCommandExecute) {
options.onCommandExecute(command);
}
},
}));
// Sync roving focus changes with selected index
rovingFocusBehavior.subscribe((rovingState) => {
store.actions.updateSelectedIndex(rovingState.currentIndex);
});
return {
getState: store.getState,
subscribe: store.subscribe,
actions: store.actions,
rovingFocus: rovingFocusBehavior,
destroy: () => {
dialogBehavior.destroy();
rovingFocusBehavior.destroy();
store.destroy();
},
};
}This pattern demonstrates multi-behavior composition:
- Dialog Behavior: Manages open/close state
- Roving Focus Behavior: Handles keyboard navigation through filtered commands
- Store: Manages command registry, search query, filtered results
- Fuzzy Search: Custom logic for filtering commands based on query
- Synchronization: Roving focus changes update the selected index in the store
The pattern doesn't just compose behaviors—it orchestrates them, ensuring they work together seamlessly.
Using Command Palette in GreenWatch
Let's build a command palette for the greenhouse monitoring system:
// GreenWatch command palette
import { createCommandPalette } from '@web-loom/ui-patterns';
function GreenWatchCommandPalette() {
const palette = createCommandPalette({
commands: [
{
id: 'new-greenhouse',
label: 'Create New Greenhouse',
category: 'Greenhouse',
keywords: ['add', 'create', 'new'],
shortcut: 'Ctrl+N',
action: () => {
// Navigate to greenhouse creation
router.push('/greenhouses/new');
},
},
{
id: 'add-sensor',
label: 'Add Sensor',
category: 'Sensors',
keywords: ['create', 'new', 'sensor'],
shortcut: 'Ctrl+Shift+S',
action: () => {
router.push('/sensors/new');
},
},
{
id: 'view-alerts',
label: 'View Threshold Alerts',
category: 'Monitoring',
keywords: ['alerts', 'warnings', 'notifications'],
shortcut: 'Ctrl+A',
action: () => {
router.push('/alerts');
},
},
{
id: 'export-data',
label: 'Export Sensor Data',
category: 'Data',
keywords: ['download', 'export', 'csv'],
action: async () => {
await exportSensorData();
},
},
],
onCommandExecute: (command) => {
console.log('Executed command:', command.label);
// Track analytics
trackEvent('command_executed', { commandId: command.id });
},
});
const [state, setState] = useState(palette.getState());
useEffect(() => {
return palette.subscribe(setState);
}, []);
// Global keyboard shortcut to open palette
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
palette.actions.open();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
if (!state.isOpen) return null;
return (
<div className="command-palette-overlay" onClick={() => palette.actions.close()}>
<div className="command-palette" onClick={(e) => e.stopPropagation()}>
{/* Search input */}
<input
type="text"
placeholder="Type a command or search..."
value={state.query}
onChange={(e) => palette.actions.setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
palette.actions.selectNext();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
palette.actions.selectPrevious();
} else if (e.key === 'Enter') {
e.preventDefault();
palette.actions.executeSelected();
} else if (e.key === 'Escape') {
palette.actions.close();
}
}}
autoFocus
/>
{/* Filtered commands */}
<div className="command-list">
{state.filteredCommands.length === 0 ? (
<div className="empty-state">No commands found</div>
) : (
state.filteredCommands.map((command, index) => (
<div
key={command.id}
className={`command-item ${index === state.selectedIndex ? 'selected' : ''}`}
onClick={() => palette.actions.executeCommand(command.id)}
>
<div className="command-label">{command.label}</div>
{command.category && (
<div className="command-category">{command.category}</div>
)}
{command.shortcut && (
<div className="command-shortcut">{command.shortcut}</div>
)}
</div>
))
)}
</div>
</div>
</div>
);
}The command palette provides a powerful, keyboard-driven interface for accessing application features. The pattern handles all the complexity—fuzzy search, keyboard navigation, command execution—while you focus on defining commands and styling the UI.
Event-Driven Pattern Communication
One of the most powerful aspects of composed patterns is event-driven communication. Patterns emit events that other components can listen to, enabling loose coupling and flexible architectures.
Every pattern in ui-patterns includes an event bus:
// Master-Detail events
masterDetail.eventBus.on('item:selected', (item) => {
console.log('Item selected:', item);
// Load additional data, trigger analytics, update other components
});
masterDetail.eventBus.on('selection:cleared', () => {
console.log('Selection cleared');
// Reset detail view, clear related state
});
// Modal events
modal.eventBus.on('modal:opened', (modal) => {
console.log('Modal opened:', modal.id);
// Pause background processes, track analytics
});
modal.eventBus.on('modal:escape-pressed', (modalId) => {
console.log('Escape pressed on modal:', modalId);
// Track user behavior, show confirmation if needed
});
modal.eventBus.on('modal:backdrop-clicked', (modalId) => {
console.log('Backdrop clicked on modal:', modalId);
// Track user behavior
});This event-driven approach enables pattern composition at the application level. You can connect patterns together through events:
// Connect Master-Detail with Modal for editing
const masterDetail = createMasterDetail<Sensor>({
items: sensors,
getId: (sensor) => sensor.id,
});
const editModal = createModal();
// When an item is selected, open edit modal
masterDetail.eventBus.on('item:selected', (sensor) => {
editModal.actions.openModalWithConfig({
id: 'edit-sensor',
content: { sensor },
closeOnEscape: true,
closeOnBackdropClick: false,
});
});
// When modal closes, refresh the list
editModal.eventBus.on('modal:closed', (modalId) => {
if (modalId === 'edit-sensor') {
// Refresh sensor list
refreshSensors();
}
});Events enable declarative pattern orchestration. Instead of imperatively calling methods, you declare relationships between patterns through event listeners. This makes complex interactions easier to understand and maintain.
Other Composed Patterns in ui-patterns
The Web Loom monorepo includes several other composed patterns worth exploring:
Tabbed Interface Pattern
Manages tab-based navigation with keyboard support:
import { createTabbedInterface } from '@web-loom/ui-patterns';
const tabs = createTabbedInterface({
tabs: [
{ id: 'overview', label: 'Overview', disabled: false },
{ id: 'sensors', label: 'Sensors', disabled: false },
{ id: 'alerts', label: 'Alerts', disabled: false },
],
initialActiveTabId: 'overview',
onTabChange: (tab) => {
console.log('Active tab:', tab.label);
},
});
// Navigate tabs
tabs.actions.activateTab('sensors');
tabs.actions.focusNextTab();
tabs.actions.focusPreviousTab();Composition: Combines Roving Focus for keyboard navigation with tab state management.
Sidebar Shell Pattern
Manages collapsible navigation sidebar with responsive behavior:
import { createSidebarShell } from '@web-loom/ui-patterns';
const sidebar = createSidebarShell({
initialCollapsed: false,
initialMobileOpen: false,
onToggle: (isCollapsed) => {
console.log('Sidebar collapsed:', isCollapsed);
},
});
// Toggle sidebar
sidebar.actions.toggle();
sidebar.actions.toggleMobile();Composition: Combines Disclosure behavior with responsive state management for mobile/desktop modes.
Toast Queue Pattern
Manages notification queue with auto-dismiss and priority:
import { createToastQueue } from '@web-loom/ui-patterns';
const toasts = createToastQueue({
maxVisible: 3,
defaultDuration: 5000,
position: 'top-right',
});
// Add toast
const toastId = toasts.actions.addToast({
message: 'Sensor reading saved successfully',
type: 'success',
duration: 3000,
});
// Remove toast
toasts.actions.removeToast(toastId);Composition: Combines queue management with timer-based auto-dismiss logic.
Each pattern demonstrates different composition strategies, but all follow the same principles: compose atomic behaviors, add orchestration logic, expose a clean API, emit events for integration.
Building Your Own Composed Patterns
You don't need a library to build composed patterns. The principles we've discussed apply to any codebase. Here's a framework for creating your own patterns:
Step 1: Identify the Workflow
What user workflow are you trying to support? Break it down into discrete steps and interactions.
Example: A "Data Export Wizard" for exporting sensor readings.
Workflow:
- Select date range
- Select sensors to include
- Choose export format (CSV, JSON, Excel)
- Preview data
- Download file
Step 2: Identify Atomic Behaviors
What atomic behaviors does this workflow need?
- Wizard behavior: Multi-step navigation with validation
- List selection behavior: Selecting multiple sensors
- Form behavior: Date range inputs with validation
- Dialog behavior: Preview modal
Step 3: Compose Behaviors
Create a pattern that composes these behaviors:
import { createWizard, createListSelection, createFormBehavior, createDialogBehavior } from '@web-loom/ui-core';
import { createStore } from '@web-loom/store-core';
interface ExportWizardData {
dateRange?: { start: Date; end: Date };
sensorIds?: string[];
format?: 'csv' | 'json' | 'excel';
}
export function createDataExportWizard(sensors: Sensor[]) {
// Compose: Wizard for step management
const wizard = createWizard<ExportWizardData>({
steps: [
{
id: 'date-range',
label: 'Select Date Range',
validate: (data) => {
if (!data.dateRange) return 'Date range is required';
if (data.dateRange.start > data.dateRange.end) {
return 'Start date must be before end date';
}
return null;
},
},
{
id: 'sensors',
label: 'Select Sensors',
validate: (data) => {
if (!data.sensorIds || data.sensorIds.length === 0) {
return 'At least one sensor must be selected';
}
return null;
},
},
{
id: 'format',
label: 'Choose Format',
validate: (data) => {
if (!data.format) return 'Export format is required';
return null;
},
},
{
id: 'preview',
label: 'Preview & Download',
},
],
onComplete: async (data) => {
await exportData(data);
},
});
// Compose: List selection for sensor selection
const sensorSelection = createListSelection({
items: sensors.map((s) => s.id),
mode: 'multi',
onSelectionChange: (selectedIds) => {
wizard.actions.setStepData({ sensorIds: selectedIds });
},
});
// Compose: Dialog for preview
const previewDialog = createDialogBehavior({
id: 'export-preview',
});
return {
wizard,
sensorSelection,
previewDialog,
destroy: () => {
wizard.destroy();
sensorSelection.destroy();
previewDialog.destroy();
},
};
}Step 4: Add Orchestration Logic
Add the glue code that coordinates behaviors:
// In the component using the pattern
const exportWizard = createDataExportWizard(sensors);
// Orchestration: When reaching preview step, open preview dialog
useEffect(() => {
const unsubscribe = exportWizard.wizard.subscribe((state) => {
const currentStep = state.steps[state.currentStepIndex];
if (currentStep.id === 'preview') {
// Load preview data
const previewData = generatePreview(state.data);
exportWizard.previewDialog.actions.open(previewData);
}
});
return unsubscribe;
}, []);Step 5: Expose Clean API
Provide a simple, intuitive API for consumers:
// Usage
const exportWizard = createDataExportWizard(sensors);
// Simple API
exportWizard.wizard.actions.goToNextStep();
exportWizard.sensorSelection.actions.select('sensor-1');
exportWizard.previewDialog.actions.open(previewData);
// Clean up
exportWizard.destroy();This five-step process works for any composed pattern. The key is identifying the workflow, breaking it into atomic behaviors, composing them, adding orchestration, and exposing a clean API.
Composed Patterns and MVVM Architecture
Composed patterns fit naturally into MVVM architecture as reusable presentation logic. They sit between ViewModels and Views, providing common UI workflows that work across frameworks.
Here's how the layers interact:
┌─────────────────────────────────────────────────────────┐
│ View Layer │
│ (React, Vue, Angular, Vanilla JS) │
│ │
│ - Renders UI based on pattern state │
│ - Handles user interactions │
│ - Subscribes to pattern state changes │
└─────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────┐
│ Composed Patterns │
│ (Master-Detail, Wizard, Modal, etc.) │
│ │
│ - Orchestrates atomic behaviors │
│ - Manages workflow state │
│ - Emits events for integration │
│ - Framework-agnostic │
└─────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────┐
│ Atomic Behaviors │
│ (Dialog, Form, List Selection, Roving Focus) │
│ │
│ - Provides individual interaction patterns │
│ - Framework-agnostic │
│ - Composable building blocks │
└─────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────┐
│ ViewModels │
│ (SensorViewModel, GreenhouseViewModel) │
│ │
│ - Domain-specific presentation logic │
│ - Connects to Models │
│ - Exposes data and actions to Views │
└─────────────────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────────────────┐
│ Models │
│ (SensorModel, GreenhouseModel) │
│ │
│ - Domain logic and data │
│ - API communication │
│ - Validation │
└─────────────────────────────────────────────────────────┘
Composed patterns provide workflow orchestration that ViewModels can leverage. For example:
// ViewModel uses composed pattern for sensor configuration workflow
class SensorConfigurationViewModel extends BaseViewModel {
private wizard: WizardBehavior<SensorConfig>;
constructor(private sensorModel: SensorModel) {
super();
this.wizard = createWizard({
steps: [
{ id: 'basic', label: 'Basic Info', validate: this.validateBasicInfo },
{ id: 'thresholds', label: 'Thresholds', validate: this.validateThresholds },
{ id: 'alerts', label: 'Alert Settings', validate: this.validateAlerts },
],
onComplete: async (data) => {
await this.sensorModel.updateConfiguration(data);
},
});
}
get wizardState$() {
return new Observable((subscriber) => {
return this.wizard.subscribe((state) => subscriber.next(state));
});
}
goToNextStep = async () => {
return await this.wizard.actions.goToNextStep();
};
dispose() {
this.wizard.destroy();
super.dispose();
}
}The ViewModel wraps the pattern, exposing it to Views through observables. This keeps the ViewModel focused on domain logic while delegating workflow orchestration to the pattern.
Pattern Libraries: ui-patterns and Beyond
The Web Loom monorepo's ui-patterns package demonstrates these composition principles in production-ready code. But it's not the only option. The ecosystem includes several excellent pattern libraries:
Radix UI (React)
Radix provides unstyled, accessible UI primitives for React. While framework-specific, it demonstrates excellent pattern composition:
import * as Dialog from '@radix-ui/react-dialog';
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>Headless UI (React/Vue)
Tailwind's Headless UI provides unstyled components for React and Vue:
import { Dialog } from '@headlessui/react';
<Dialog open={isOpen} onClose={() => setIsOpen(false)}>
<Dialog.Panel>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
</Dialog.Panel>
</Dialog>Ark UI (React/Vue/Solid)
Ark UI provides framework-agnostic patterns similar to ui-patterns:
import { Dialog } from '@ark-ui/react';
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>Each library has different APIs and philosophies, but all share the same core principle: separate behavior from presentation. Understanding this principle lets you use any library effectively—or build your own when needed.
Key Takeaways
Composed UI patterns are essential for building maintainable, reusable MVVM applications:
-
Patterns compose atomic behaviors: Complex workflows are built by combining simpler behaviors like Dialog, Form, List Selection, and Roving Focus.
-
Composition enables reusability: Write a pattern once, use it across all frameworks and domains.
-
Patterns add orchestration: Beyond composing behaviors, patterns add workflow logic that coordinates behaviors into cohesive experiences.
-
Event-driven communication: Patterns emit events that enable loose coupling and flexible architectures.
-
Framework-agnostic by design: Patterns work in React, Vue, Angular, or vanilla JS because they separate logic from presentation.
-
Patterns fit naturally in MVVM: They provide reusable presentation logic that ViewModels can leverage, sitting between ViewModels and Views.
-
You can build your own: The composition principles are universal. Identify workflows, compose behaviors, add orchestration, expose clean APIs.
In the next chapter, we'll shift from framework-agnostic patterns to advanced MVVM topics, exploring Domain-Driven Design principles for frontend architecture. We'll see how DDD concepts like bounded contexts, aggregates, and domain events apply to frontend applications, using the GreenWatch system as our case study.