Pattern 17: State-Aware Behavior
Part II: Interaction Patterns - Temporal Patterns
Opening Scenario: The Form That Changed Its Personality
Monica was reviewing expense reports in the company's expense system. She opened Report #3847, submitted by David three days ago.
The form looked identical to when David had created it:
Expense Report #3847
Amount: $1,247.38 [Editable text field]
Category: [Travel ▼] [Editable dropdown]
Description: [ ] [Editable textarea]
[Submit] [Delete]
Monica could edit everything. She accidentally changed the amount from $1,247.38 to $1,247.83. She saved it.
David got a notification: "Your expense report has been modified."
He looked at his report. "I submitted $1,247.38. Why does it now show $1,247.83? Did I make a mistake?"
No - Monica had accidentally changed it while reviewing. But the form treated her review session the same as David's creation session.
The IT director, Susan, investigated. The form had no concept of "state":
// Bad: Same behavior regardless of state
function renderExpenseForm(report) {
return `
<input name="amount" value="${report.amount}">
<select name="category">...</select>
<textarea name="description">${report.description}</textarea>
<button>Submit</button>
<button>Delete</button>
`;
}
// Creator, reviewer, accountant - everyone sees the same form
// No awareness of:
// - Who is viewing
// - What state the report is in
// - What actions are appropriate
Susan mapped out the actual workflow:
Draft → Submitted → Under Review → Approved → Paid
↓
Rejected → Draft (resubmit)
Each state should have different behavior:
Draft State (creator only): - All fields editable - Can save without submitting - Can delete draft - "Submit for Review" button
Submitted State (creator can view, not edit): - All fields read-only for creator - "Withdraw" button (return to draft) - Status: "Waiting for review"
Under Review (reviewer only): - Amount/Category read-only (preserve creator's data) - Can add reviewer notes - "Approve" or "Reject" buttons - "Request Changes" option
Approved State (read-only for all): - All fields locked - Shows approval timestamp and approver - "Export to PDF" option - Waiting for payment processing
Rejected State (creator can revise): - Rejection reason shown prominently - Creator can edit and resubmit - Original values preserved for reference
Susan built a state-aware system:
class StateAwareForm {
constructor(document, currentUser) {
this.document = document;
this.currentUser = currentUser;
this.state = document.state;
}
render() {
const stateConfig = this.getStateConfiguration();
return this.renderWithConfiguration(stateConfig);
}
getStateConfiguration() {
const configs = {
'draft': {
editable: ['amount', 'category', 'description', 'receipts'],
visible: ['all'],
actions: ['save', 'submit', 'delete'],
restrictions: [],
message: 'Complete your expense report'
},
'submitted': {
editable: [], // Nothing editable
visible: ['all'],
actions: ['withdraw'],
restrictions: ['no-edit'],
message: 'Submitted - Waiting for review',
canView: ['creator', 'reviewer', 'accountant']
},
'under_review': {
editable: ['reviewer_notes', 'reviewer_category_adjustment'],
visible: ['all'],
actions: ['approve', 'reject', 'request_changes'],
restrictions: ['creator-locked'],
message: 'Under review by ' + this.document.reviewer,
canEdit: ['reviewer']
},
'approved': {
editable: [],
visible: ['all'],
actions: ['export_pdf'],
restrictions: ['fully-locked'],
message: 'Approved on ' + this.document.approvalDate,
badge: 'success'
},
'rejected': {
editable: ['amount', 'category', 'description', 'receipts'],
visible: ['all', 'rejection_reason'],
actions: ['resubmit'],
restrictions: [],
message: 'Rejected - See feedback below',
badge: 'error'
}
};
return configs[this.state];
}
}
Now when Monica reviewed a submitted expense:
Expense Report #3847 [UNDER REVIEW]
Amount: $1,247.38 [Read-only, locked 🔒]
Category: Travel [Read-only, locked 🔒]
Description: ... [Read-only, locked 🔒]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
REVIEWER SECTION
Reviewer Notes: [ ]
Your comments for the submitter
Category Adjustment: [Travel ▼]
Correct category if needed
[Approve] [Reject] [Request Changes]
Monica couldn't accidentally edit David's data. The form knew its state and adapted its behavior.
When David viewed his submitted report:
Expense Report #3847 [SUBMITTED]
Status: Waiting for Monica's review
Submitted: Dec 12, 2024 at 3:47 PM
Amount: $1,247.38 [Your submission 👁️]
Category: Travel
Description: ...
[Withdraw Submission]
Everything read-only for him, but he could withdraw if needed.
After approval:
Expense Report #3847 [✓ APPROVED]
Approved by: Monica Chen
Approved on: Dec 15, 2024 at 11:23 AM
Amount: $1,247.38 [Approved ✓]
Category: Travel [Approved ✓]
Description: ... [Approved ✓]
Payment Status: Processing (ETA: Dec 20)
[Export PDF] [Print]
Everything locked. Clear status. Appropriate actions only.
Accidental edits: Zero. User confusion: Eliminated. Workflow clarity: Perfect.
Context
State-Aware Behavior applies when:
Documents have lifecycle: Draft → Submitted → Approved → Archived
Permissions vary by state: Different users can do different things at different stages
Validation changes: Rules differ between draft and final submission
UI should adapt: What's editable, visible, and actionable changes
Workflow has branches: Approval, rejection, revision paths
Audit requirements: Track who did what in which state
User roles matter: Creator, reviewer, approver have different capabilities
Problem Statement
Most forms ignore workflow state, treating all interactions identically:
State-blind rendering:
// Bad: Same form regardless of state
function renderForm(data) {
return `
<input name="field1" value="${data.field1}">
<input name="field2" value="${data.field2}">
<button>Submit</button>
`;
}
// Draft? Submitted? Approved?
// Form doesn't know, doesn't care
No permission enforcement:
// Bad: Anyone can edit anything anytime
app.post('/update', (req, res) => {
document.update(req.body);
// No check of:
// - Document state
// - User permissions
// - Allowed actions
});
Inappropriate actions available:
<!-- Bad: Delete button on approved documents -->
<form>
<input name="amount" value="$5000">
<button>Save</button>
<button>Submit</button>
<button>Delete</button> <!-- Approved docs shouldn't be deletable! -->
</form>
No state indication:
<!-- Bad: No visual indication of state -->
<h1>Expense Report #3847</h1>
<input name="amount" value="$1247.38">
<!-- Is this:
- A draft I'm creating?
- A submitted report I'm reviewing?
- An approved report I'm viewing?
User can't tell! -->
State transitions uncontrolled:
// Bad: Any state can become any other state
document.state = newState; // No validation
// Can go from Draft directly to Paid?
// Can go from Approved back to Draft?
// No workflow enforcement
We need forms that understand their state, adapt their behavior accordingly, and enforce proper workflow transitions.
Forces
Flexibility vs Control
- Users want flexibility to edit
- But state requires restrictions
- Balance freedom with workflow integrity
Simplicity vs State Complexity
- Simple forms are easy
- But workflows are complex
- Balance ease-of-use with capability
Permissions vs Usability
- Strict permissions prevent errors
- But can frustrate legitimate actions
- Balance security with efficiency
Transparency vs Clutter
- State should be obvious
- But too many indicators overwhelm
- Balance clarity with cleanliness
Consistency vs Context
- Consistent UI is learnable
- But context requires adaptation
- Balance familiarity with appropriateness
Solution
Implement state machine that defines document lifecycle, configures form behavior per state, enforces transition rules, and provides clear visual indication of current state and available actions.
The pattern has six key strategies:
1. State Machine Definition
Define all possible states and transitions:
class DocumentStateMachine {
constructor(documentType) {
this.documentType = documentType;
this.states = this.defineStates();
this.transitions = this.defineTransitions();
}
defineStates() {
return {
'draft': {
name: 'Draft',
description: 'Being created or edited',
color: '#95a5a6',
icon: '✏️',
terminal: false
},
'submitted': {
name: 'Submitted',
description: 'Awaiting review',
color: '#3498db',
icon: '📤',
terminal: false
},
'under_review': {
name: 'Under Review',
description: 'Being reviewed by approver',
color: '#f39c12',
icon: '👁️',
terminal: false
},
'approved': {
name: 'Approved',
description: 'Approved for processing',
color: '#27ae60',
icon: '✓',
terminal: false
},
'rejected': {
name: 'Rejected',
description: 'Returned for revision',
color: '#e74c3c',
icon: '✗',
terminal: false
},
'paid': {
name: 'Paid',
description: 'Payment completed',
color: '#16a085',
icon: '💰',
terminal: true // Final state
},
'cancelled': {
name: 'Cancelled',
description: 'Cancelled by creator',
color: '#7f8c8d',
icon: '🚫',
terminal: true // Final state
}
};
}
defineTransitions() {
return [
// From Draft
{ from: 'draft', to: 'submitted', action: 'submit',
requires: ['complete-data'], actor: 'creator' },
{ from: 'draft', to: 'cancelled', action: 'cancel',
actor: 'creator' },
// From Submitted
{ from: 'submitted', to: 'under_review', action: 'assign_reviewer',
actor: 'manager' },
{ from: 'submitted', to: 'draft', action: 'withdraw',
actor: 'creator' },
// From Under Review
{ from: 'under_review', to: 'approved', action: 'approve',
requires: ['reviewer-notes'], actor: 'reviewer' },
{ from: 'under_review', to: 'rejected', action: 'reject',
requires: ['rejection-reason'], actor: 'reviewer' },
{ from: 'under_review', to: 'submitted', action: 'request_changes',
requires: ['change-request'], actor: 'reviewer' },
// From Rejected
{ from: 'rejected', to: 'submitted', action: 'resubmit',
requires: ['addressed-feedback'], actor: 'creator' },
{ from: 'rejected', to: 'cancelled', action: 'cancel',
actor: 'creator' },
// From Approved
{ from: 'approved', to: 'paid', action: 'process_payment',
actor: 'accountant' }
];
}
canTransition(fromState, toState, actor, document) {
// Find the transition
const transition = this.transitions.find(t =>
t.from === fromState && t.to === toState
);
if (!transition) {
return {
allowed: false,
reason: `No transition from ${fromState} to ${toState}`
};
}
// Check actor permission
if (transition.actor && !this.hasRole(actor, transition.actor)) {
return {
allowed: false,
reason: `Only ${transition.actor} can perform this action`
};
}
// Check requirements
if (transition.requires) {
for (const requirement of transition.requires) {
if (!this.checkRequirement(requirement, document)) {
return {
allowed: false,
reason: `Missing: ${requirement}`
};
}
}
}
return { allowed: true };
}
getAvailableTransitions(currentState, actor, document) {
return this.transitions
.filter(t => t.from === currentState)
.filter(t => {
const check = this.canTransition(currentState, t.to, actor, document);
return check.allowed;
})
.map(t => ({
action: t.action,
toState: t.to,
label: this.getActionLabel(t.action),
requires: t.requires || []
}));
}
getActionLabel(action) {
const labels = {
'submit': 'Submit for Review',
'withdraw': 'Withdraw Submission',
'approve': 'Approve',
'reject': 'Reject',
'resubmit': 'Resubmit',
'cancel': 'Cancel',
'process_payment': 'Process Payment'
};
return labels[action] || action;
}
}
2. State-Based Configuration
Configure form behavior per state:
class StateConfiguration {
constructor(stateMachine) {
this.stateMachine = stateMachine;
this.configs = this.defineConfigurations();
}
defineConfigurations() {
return {
'draft': {
fields: {
editable: ['all'],
required: ['amount', 'category'],
visible: ['all'],
readonly: []
},
validation: {
strictness: 'lenient', // Allow saving incomplete
showWarnings: true,
blockSubmit: ['amount', 'category', 'description']
},
actions: {
available: ['save', 'submit', 'cancel'],
primary: 'save',
destructive: 'cancel'
},
ui: {
showAutosave: true,
showProgress: true,
allowAttachments: true,
showDraftIndicator: true
}
},
'submitted': {
fields: {
editable: [],
required: [],
visible: ['all'],
readonly: ['all']
},
validation: {
strictness: 'strict',
showWarnings: false,
blockSubmit: []
},
actions: {
available: ['withdraw', 'export'],
primary: null,
destructive: 'withdraw'
},
ui: {
showAutosave: false,
showProgress: false,
allowAttachments: false,
showSubmissionInfo: true
}
},
'under_review': {
fields: {
editable: ['reviewer_notes', 'reviewer_adjustments'],
required: [],
visible: ['all', 'reviewer_fields'],
readonly: ['creator_fields']
},
validation: {
strictness: 'strict',
showWarnings: true,
blockSubmit: ['reviewer_notes'] // Required for approve/reject
},
actions: {
available: ['approve', 'reject', 'request_changes'],
primary: 'approve',
destructive: 'reject'
},
ui: {
showAutosave: true,
showProgress: false,
allowAttachments: true,
highlightReviewerSection: true
}
},
'approved': {
fields: {
editable: [],
required: [],
visible: ['all', 'approval_info'],
readonly: ['all']
},
validation: {
strictness: 'none', // No editing possible
showWarnings: false,
blockSubmit: []
},
actions: {
available: ['export', 'print'],
primary: 'export',
destructive: null
},
ui: {
showAutosave: false,
showProgress: false,
allowAttachments: false,
showApprovalBanner: true
}
},
'rejected': {
fields: {
editable: ['all'],
required: ['amount', 'category', 'description'],
visible: ['all', 'rejection_info'],
readonly: []
},
validation: {
strictness: 'strict',
showWarnings: true,
blockSubmit: ['amount', 'category', 'description']
},
actions: {
available: ['save', 'resubmit', 'cancel'],
primary: 'resubmit',
destructive: 'cancel'
},
ui: {
showAutosave: true,
showProgress: true,
allowAttachments: true,
showRejectionFeedback: true,
highlightChangesNeeded: true
}
}
};
}
getConfiguration(state) {
return this.configs[state] || this.configs['draft'];
}
isFieldEditable(fieldName, state) {
const config = this.getConfiguration(state);
if (config.fields.editable.includes('all')) {
return !config.fields.readonly.includes(fieldName);
}
return config.fields.editable.includes(fieldName);
}
isFieldVisible(fieldName, state) {
const config = this.getConfiguration(state);
if (config.fields.visible.includes('all')) {
return true;
}
return config.fields.visible.includes(fieldName);
}
getAvailableActions(state) {
const config = this.getConfiguration(state);
return config.actions.available;
}
}
3. Permission-Aware Rendering
Render based on state and user role:
class StateAwareRenderer {
constructor(stateMachine, stateConfig) {
this.stateMachine = stateMachine;
this.stateConfig = stateConfig;
}
render(document, currentUser) {
const state = document.state;
const config = this.stateConfig.getConfiguration(state);
return `
${this.renderStateIndicator(state)}
${this.renderFields(document, state, currentUser)}
${this.renderActions(document, state, currentUser)}
${this.renderStateInfo(document, state)}
`;
}
renderStateIndicator(state) {
const stateInfo = this.stateMachine.states[state];
return `
<div class="state-indicator state-${state}">
<span class="state-icon">${stateInfo.icon}</span>
<span class="state-name">${stateInfo.name}</span>
<span class="state-description">${stateInfo.description}</span>
</div>
`;
}
renderFields(document, state, currentUser) {
const config = this.stateConfig.getConfiguration(state);
let html = '<div class="document-fields">';
// Render each field based on configuration
Object.keys(document.data).forEach(fieldName => {
if (this.stateConfig.isFieldVisible(fieldName, state)) {
const editable = this.stateConfig.isFieldEditable(fieldName, state);
const value = document.data[fieldName];
html += this.renderField(fieldName, value, editable, state);
}
});
// Add state-specific fields
if (state === 'under_review') {
html += this.renderReviewerFields(document);
}
if (state === 'rejected') {
html += this.renderRejectionInfo(document);
}
if (state === 'approved') {
html += this.renderApprovalInfo(document);
}
html += '</div>';
return html;
}
renderField(fieldName, value, editable, state) {
if (editable) {
return `
<div class="field editable">
<label>${this.getFieldLabel(fieldName)}</label>
<input name="${fieldName}" value="${value}">
<span class="field-hint">Editable in ${state} state</span>
</div>
`;
} else {
return `
<div class="field readonly">
<label>${this.getFieldLabel(fieldName)}</label>
<div class="field-value">
${value}
<span class="lock-icon">🔒</span>
</div>
<span class="field-hint">Locked in ${state} state</span>
</div>
`;
}
}
renderActions(document, state, currentUser) {
const availableTransitions = this.stateMachine.getAvailableTransitions(
state,
currentUser,
document
);
if (availableTransitions.length === 0) {
return '';
}
const config = this.stateConfig.getConfiguration(state);
let html = '<div class="document-actions">';
availableTransitions.forEach(transition => {
const isPrimary = transition.action === config.actions.primary;
const isDestructive = transition.action === config.actions.destructive;
let buttonClass = 'action-button';
if (isPrimary) buttonClass += ' primary';
if (isDestructive) buttonClass += ' destructive';
html += `
<button
class="${buttonClass}"
data-action="${transition.action}"
data-to-state="${transition.toState}"
>
${transition.label}
</button>
`;
});
html += '</div>';
return html;
}
renderStateInfo(document, state) {
let html = '<div class="state-info">';
if (state === 'submitted') {
html += `
<p>Submitted on ${document.submittedDate}</p>
<p>Waiting for review assignment</p>
`;
}
if (state === 'under_review') {
html += `
<p>Assigned to: ${document.reviewer}</p>
<p>Review started: ${document.reviewStartDate}</p>
`;
}
if (state === 'approved') {
html += `
<p>Approved by: ${document.approver}</p>
<p>Approved on: ${document.approvalDate}</p>
<p>Next: Payment processing</p>
`;
}
html += '</div>';
return html;
}
renderReviewerFields(document) {
return `
<div class="reviewer-section">
<h3>Reviewer Section</h3>
<div class="field editable">
<label>Reviewer Notes</label>
<textarea name="reviewer_notes" rows="4"></textarea>
<span class="field-hint">Your comments for the submitter</span>
</div>
<div class="field editable">
<label>Category Adjustment</label>
<select name="reviewer_category">
<option>Keep as submitted</option>
<option>Travel</option>
<option>Meals</option>
<option>Equipment</option>
</select>
</div>
</div>
`;
}
renderRejectionInfo(document) {
return `
<div class="rejection-feedback">
<h3>Rejection Feedback</h3>
<div class="feedback-box">
<p><strong>Reason:</strong></p>
<p>${document.rejectionReason}</p>
<p><strong>Requested Changes:</strong></p>
<p>${document.changesRequested}</p>
</div>
</div>
`;
}
renderApprovalInfo(document) {
return `
<div class="approval-info success">
<h3>✓ Approval Details</h3>
<dl>
<dt>Approved By:</dt>
<dd>${document.approver}</dd>
<dt>Approval Date:</dt>
<dd>${document.approvalDate}</dd>
<dt>Approval Notes:</dt>
<dd>${document.approvalNotes || 'None'}</dd>
</dl>
</div>
`;
}
}
4. State Transition Validation
Ensure only valid transitions occur:
class StateTransitionValidator {
constructor(stateMachine) {
this.stateMachine = stateMachine;
}
validateTransition(document, toState, actor, additionalData = {}) {
const currentState = document.state;
// Can this transition happen?
const canTransition = this.stateMachine.canTransition(
currentState,
toState,
actor,
document
);
if (!canTransition.allowed) {
return {
valid: false,
errors: [canTransition.reason]
};
}
// Get the transition
const transition = this.stateMachine.transitions.find(t =>
t.from === currentState && t.to === toState
);
// Validate requirements
const errors = [];
if (transition.requires) {
transition.requires.forEach(requirement => {
if (!this.validateRequirement(requirement, document, additionalData)) {
errors.push(`Missing: ${requirement}`);
}
});
}
// State-specific validation
const stateValidation = this.validateStateSpecificRules(
currentState,
toState,
document,
additionalData
);
if (!stateValidation.valid) {
errors.push(...stateValidation.errors);
}
return {
valid: errors.length === 0,
errors: errors,
transition: transition
};
}
validateRequirement(requirement, document, additionalData) {
switch(requirement) {
case 'complete-data':
return document.amount &&
document.category &&
document.description;
case 'reviewer-notes':
return additionalData.reviewer_notes &&
additionalData.reviewer_notes.length > 10;
case 'rejection-reason':
return additionalData.rejection_reason &&
additionalData.rejection_reason.length > 20;
case 'addressed-feedback':
// Check if changes were made since rejection
return document.lastModified > document.rejectedDate;
default:
return true;
}
}
validateStateSpecificRules(fromState, toState, document, data) {
const errors = [];
// Draft → Submitted
if (fromState === 'draft' && toState === 'submitted') {
if (!document.amount || document.amount <= 0) {
errors.push('Amount must be greater than zero');
}
if (!document.category) {
errors.push('Category is required');
}
if (!document.description || document.description.length < 10) {
errors.push('Description must be at least 10 characters');
}
if (!document.receipts || document.receipts.length === 0) {
errors.push('At least one receipt is required');
}
}
// Under Review → Approved
if (fromState === 'under_review' && toState === 'approved') {
if (!data.reviewer_notes) {
errors.push('Reviewer notes are required for approval');
}
}
// Under Review → Rejected
if (fromState === 'under_review' && toState === 'rejected') {
if (!data.rejection_reason) {
errors.push('Rejection reason is required');
}
if (!data.changes_requested) {
errors.push('Must specify what changes are needed');
}
}
return {
valid: errors.length === 0,
errors: errors
};
}
executeTransition(document, toState, actor, data) {
// Validate first
const validation = this.validateTransition(document, toState, actor, data);
if (!validation.valid) {
throw new Error(`Invalid transition: ${validation.errors.join(', ')}`);
}
// Record transition
const transition = {
from: document.state,
to: toState,
action: validation.transition.action,
actor: actor,
timestamp: new Date(),
data: data
};
// Update document
document.state = toState;
document.stateHistory = document.stateHistory || [];
document.stateHistory.push(transition);
// State-specific updates
this.updateDocumentForState(document, toState, actor, data);
return {
success: true,
newState: toState,
transition: transition
};
}
updateDocumentForState(document, newState, actor, data) {
switch(newState) {
case 'submitted':
document.submittedDate = new Date();
document.submitter = actor;
break;
case 'under_review':
document.reviewStartDate = new Date();
document.reviewer = actor;
break;
case 'approved':
document.approvalDate = new Date();
document.approver = actor;
document.approvalNotes = data.reviewer_notes;
break;
case 'rejected':
document.rejectedDate = new Date();
document.rejectedBy = actor;
document.rejectionReason = data.rejection_reason;
document.changesRequested = data.changes_requested;
break;
case 'paid':
document.paidDate = new Date();
document.paidBy = actor;
break;
}
}
}
5. State History & Audit
Track all state changes:
class StateHistoryTracker {
constructor() {
this.history = [];
}
recordTransition(transition) {
this.history.push({
timestamp: transition.timestamp,
from: transition.from,
to: transition.to,
action: transition.action,
actor: transition.actor,
data: transition.data
});
}
getHistory(document) {
return document.stateHistory || [];
}
renderTimeline(document) {
const history = this.getHistory(document);
let html = '<div class="state-timeline">';
html += '<h3>Document History</h3>';
html += '<ol class="timeline">';
history.forEach(event => {
html += `
<li class="timeline-event state-${event.to}">
<div class="event-time">${this.formatTime(event.timestamp)}</div>
<div class="event-state">${event.to}</div>
<div class="event-actor">${event.actor}</div>
<div class="event-action">${event.action}</div>
</li>
`;
});
html += '</ol>';
html += '</div>';
return html;
}
formatTime(timestamp) {
return new Date(timestamp).toLocaleString();
}
getStateMetrics(document) {
const history = this.getHistory(document);
return {
totalTransitions: history.length,
timeInDraft: this.calculateTimeInState(history, 'draft'),
timeInReview: this.calculateTimeInState(history, 'under_review'),
totalProcessingTime: this.calculateTotalTime(history),
actors: this.getUniqueActors(history)
};
}
calculateTimeInState(history, state) {
let totalTime = 0;
let enterTime = null;
history.forEach(event => {
if (event.to === state) {
enterTime = event.timestamp;
} else if (enterTime && event.from === state) {
totalTime += event.timestamp - enterTime;
enterTime = null;
}
});
return totalTime;
}
calculateTotalTime(history) {
if (history.length === 0) return 0;
const first = history[0].timestamp;
const last = history[history.length - 1].timestamp;
return last - first;
}
getUniqueActors(history) {
const actors = new Set();
history.forEach(event => actors.add(event.actor));
return Array.from(actors);
}
}
6. Visual State Communication
Make state obvious to users:
class StateVisualization {
renderStateBadge(state) {
const stateInfo = this.getStateInfo(state);
return `
<div class="state-badge badge-${state}"
style="background-color: ${stateInfo.color}">
<span class="badge-icon">${stateInfo.icon}</span>
<span class="badge-text">${stateInfo.name}</span>
</div>
`;
}
renderWorkflowDiagram(currentState, stateMachine) {
let html = '<div class="workflow-diagram">';
// Get all states
const states = Object.keys(stateMachine.states);
states.forEach((state, index) => {
const stateInfo = stateMachine.states[state];
const isActive = state === currentState;
html += `
<div class="workflow-state ${isActive ? 'active' : ''}">
<div class="state-circle" style="background-color: ${stateInfo.color}">
${stateInfo.icon}
</div>
<div class="state-label">${stateInfo.name}</div>
</div>
`;
// Add arrow if not last
if (index < states.length - 1) {
html += '<div class="workflow-arrow">→</div>';
}
});
html += '</div>';
return html;
}
renderPermissionIndicator(field, canEdit, currentState) {
if (canEdit) {
return `
<span class="permission-indicator editable"
title="You can edit this field in ${currentState} state">
✏️ Editable
</span>
`;
} else {
return `
<span class="permission-indicator readonly"
title="This field is locked in ${currentState} state">
🔒 Locked
</span>
`;
}
}
renderStateHelp(currentState, availableTransitions) {
return `
<div class="state-help">
<h4>Current State: ${currentState}</h4>
<p>What you can do:</p>
<ul>
${availableTransitions.map(t => `
<li>${t.label}</li>
`).join('')}
</ul>
</div>
`;
}
}
Implementation Details
Complete State-Aware System
class StateAwareFormSystem {
constructor(documentType, config) {
this.stateMachine = new DocumentStateMachine(documentType);
this.stateConfig = new StateConfiguration(this.stateMachine);
this.renderer = new StateAwareRenderer(this.stateMachine, this.stateConfig);
this.validator = new StateTransitionValidator(this.stateMachine);
this.history = new StateHistoryTracker();
this.visualization = new StateVisualization();
}
render(document, currentUser) {
return this.renderer.render(document, currentUser);
}
canPerformAction(document, action, actor) {
const currentState = document.state;
const transitions = this.stateMachine.getAvailableTransitions(
currentState,
actor,
document
);
return transitions.some(t => t.action === action);
}
performAction(document, action, actor, data) {
// Find the transition for this action
const currentState = document.state;
const transition = this.stateMachine.transitions.find(t =>
t.from === currentState && t.action === action
);
if (!transition) {
throw new Error(`No such action: ${action} in state ${currentState}`);
}
// Execute transition
const result = this.validator.executeTransition(
document,
transition.to,
actor,
data
);
// Record in history
this.history.recordTransition(result.transition);
return result;
}
getFormConfiguration(document, currentUser) {
const state = document.state;
const config = this.stateConfig.getConfiguration(state);
const transitions = this.stateMachine.getAvailableTransitions(
state,
currentUser,
document
);
return {
state: state,
config: config,
availableActions: transitions,
stateInfo: this.stateMachine.states[state]
};
}
}
// Example usage
const expenseSystem = new StateAwareFormSystem('expense_report', {
// Configuration
});
// Render form for user
const html = expenseSystem.render(expenseReport, currentUser);
// Attempt to submit
if (expenseSystem.canPerformAction(expenseReport, 'submit', currentUser)) {
expenseSystem.performAction(expenseReport, 'submit', currentUser, {});
} else {
console.log('Cannot submit in current state');
}
Consequences
Benefits
Workflow Integrity: - Only valid transitions allowed - Business process enforced - No invalid states
Clear Permissions: - Users know what they can do - Role-appropriate actions - No accidental edits
Better UX: - Form adapts to context - Appropriate actions shown - Clear state indication
Audit Trail: - All transitions tracked - Complete history - Who did what when
Reduced Errors: - Can't approve draft - Can't edit approved - State prevents mistakes
Liabilities
Complexity: - State machines are complex - Many states to manage - Testing all paths hard
Rigidity: - Strict workflow enforcement - May block edge cases - Override mechanisms needed
User Learning Curve: - Understanding states takes time - "Why can't I edit this?" - Need clear communication
Development Overhead: - More code than simple forms - State configuration detailed - Maintenance burden
Performance: - State checks add overhead - Permission evaluation expensive - Need optimization
Domain Examples
Healthcare: Patient Records
// Medical record states
defineStates() {
return {
'in_progress': { name: 'In Progress' },
'ready_for_review': { name: 'Ready for Review' },
'reviewed': { name: 'Reviewed by Physician' },
'signed': { name: 'Signed & Locked' }
};
}
Legal: Contract Management
// Contract states
defineStates() {
return {
'draft': { name: 'Draft' },
'internal_review': { name: 'Internal Review' },
'sent_to_client': { name: 'Sent to Client' },
'client_review': { name: 'Under Client Review' },
'negotiation': { name: 'In Negotiation' },
'approved': { name: 'Approved' },
'signed': { name: 'Signed' },
'executed': { name: 'Executed' }
};
}
Finance: Invoice Processing
// Invoice states
defineStates() {
return {
'created': { name: 'Created' },
'submitted': { name: 'Submitted for Approval' },
'approved': { name: 'Approved' },
'scheduled': { name: 'Scheduled for Payment' },
'paid': { name: 'Paid' },
'disputed': { name: 'Disputed' },
'cancelled': { name: 'Cancelled' }
};
}
Related Patterns
Prerequisites: - Volume 3, Pattern 13: Conditional Requirements (requirements change by state) - Volume 3, Pattern 14: Cross-Field Validation (validation strictness varies)
Synergies: - Volume 3, Pattern 16: Temporal Validation (time-based state transitions) - Volume 3, Pattern 18: Audit Trail (track state changes) - Volume 3, Pattern 19: Version Control (state affects versioning)
Conflicts: - Extreme flexibility needs - Ad-hoc workflows - Constantly changing processes
Alternatives: - Always editable (no states) - Separate forms per state (different URLs) - Manual workflow (humans track state)
Known Uses
Document Management: Draft → Review → Approved → Published
E-commerce Orders: Cart → Placed → Processing → Shipped → Delivered
HR Systems: Application → Interview → Offer → Hired
Project Management: Planned → In Progress → Review → Complete
CRM: Lead → Qualified → Proposal → Negotiation → Closed
Bug Tracking: New → Assigned → In Progress → Fixed → Verified → Closed
Approval Workflows: All enterprise systems with multi-step approval
Further Reading
Academic Foundations
- Finite State Machines: Hopcroft, J.E., Motwani, R., & Ullman, J.D. (2006). Introduction to Automata Theory, Languages, and Computation (3rd ed.). Pearson. ISBN: 978-0321455363
- Statecharts: Harel, D. (1987). "Statecharts: A Visual Formalism for Complex Systems." Science of Computer Programming 8(3): 231-274.
- Workflow Patterns: van der Aalst, W.M.P., et al. (2003). "Workflow Patterns." Distributed and Parallel Databases 14(1): 5-51. http://www.workflowpatterns.com/
Practical Implementation
- XState: https://xstate.js.org/ - State machines and statecharts for JavaScript
- Redux: https://redux.js.org/ - Predictable state container
- Robot: https://thisrobot.life/ - Functional finite state machine library
- State Machine Cat: https://state-machine-cat.js.org/ - Visualize state machines
Standards & Specifications
- SCXML (State Chart XML): https://www.w3.org/TR/scxml/ - W3C standard for state machines
- BPMN (Business Process Model and Notation): https://www.bpmn.org/ - Workflow modeling standard
- WfMC Standards: http://www.wfmc.org/ - Workflow Management Coalition standards
Related Trilogy Patterns
- Pattern 5: Form State Tracking - Basic state management
- Pattern 11: Validation Rules - State-dependent validation
- Pattern 23: Audit Trail - Log state transitions
- Pattern 24: Version Control - Version at state changes
- Volume 2, Pattern 9: Early Warning Signals - Predict next states
- Volume 1, Chapter 8: Architecture of Domain-Specific Systems - Workflow architecture
Tools & Libraries
- Camunda: https://camunda.com/ - Open source workflow and decision automation
- Temporal: https://temporal.io/ - Durable workflow orchestration
- Apache Airflow: https://airflow.apache.org/ - Workflow automation platform
- n8n: https://n8n.io/ - Workflow automation (open source)
Implementation Examples
- State Machine Design Pattern: https://refactoring.guru/design-patterns/state
- Finite State Machines in React: https://kentcdodds.com/blog/implementing-a-simple-state-machine-library-in-javascript
- Workflow Best Practices: https://www.process.st/workflow-best-practices/