Introduction
In frontend development, UI state management becomes increasingly difficult to maintain as complexity grows. Multi-step form components, in particular, tend to accumulate unclear state variables like isNextStep
, isReviewMode
, and isComplete
over time.
This article demonstrates how we applied the State Machine pattern to an online survey form, transforming complex conditional logic into clear, maintainable code.
What is a State Machine?
A State Machine is a design pattern that defines the finite states a system can have and the transition rules between those states.
State Machines in Everyday Life
State Machines are everywhere around us:
Traffic Light System:
States: [Red, Yellow, Green]
Transitions: Red → Green → Yellow → Red
Vending Machine:
States: [Idle, CoinInserted, ProductSelected, DispensingChange]
Transitions: Idle → CoinInserted → ProductSelected → DispensingChange → Idle
Core Principles
- One State at a Time: A traffic light cannot be both red and green simultaneously
- Explicit Transitions: States only change under defined conditions
- Predictability: Knowing the current state and event allows you to predict the next state
Benefits in Programming
// ❌ Unpredictable state combinations
const [isLoading, setIsLoading] = useState(false)
const [hasError, setHasError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
// Can all three values be true at the same time?
// ✅ Clear State Machine
type FetchState = 'idle' | 'loading' | 'success' | 'error'
const [state, setState] = useState<FetchState>('idle')
// Only one state is possible at a time, eliminating confusing combinations!
Now let's see how we applied this State Machine pattern to complex form logic.
Problem: Mixed State Variables
Scenario: Multi-Step Survey Form
Our example involves a survey form with the following flow:
- Question Answering: Users respond to survey questions
- Answer Review: Users confirm their responses
- Additional Questions: Conditional advanced questions appear
- Final Submission: Submit after completing all answers
Issues with Existing Code
// ❌ Complex and confusing conditional logic
const isNextStep = currentAnswers.length > 0 && hasValidatedExtraQuestions
const canShowExtra = currentAnswers.length > 0 && currentTab === 'main'
const isComplete = step >= totalSteps && currentTab === 'extra' && isExtraValidated
// ❌ Nested conditional statements
const disabled = useMemo(() => {
if (isExtraQuestionMode) {
return (
mainAnswers.length > 0 &&
mainAnswers.every((answer) => answer.value) &&
isValidExtraAnswerFormat(extraAnswer?.value)
)
} else {
if (mainAnswers.length !== totalQuestions.length) {
return false
}
return mainAnswers.every(({ value }) => {
if (isArray(value) && isEmpty(value)) {
return false
}
if (isMultipleChoice(value)) {
return value.every((choice) => choice.selectedOptions.length > 0)
}
if (isRanking(value)) {
return value.every((item) => item.rank > 0)
}
return value
})
}
}, [/* many dependencies */])
// ❌ Complex onNext branching
const onNext = () => {
if (canShowExtra) {
showExtraQuestions()
} else if (isNextStep) {
void proceedToNextStep()
} else {
validateCurrentAnswers()
}
}
Problems with this code:
-
Poor Readability: Hard to understand if
isComplete
truly means completion - Difficult Maintenance: Adding new steps requires modifying multiple places
- Testing Complexity: Difficult to test all condition combinations
- Scattered Responsibility: Form state and behavior spread across multiple locations
Solution: Applying State Machine Pattern
Step 1: Define Clear States
First, we clearly defined the states that the survey form can have.
type SurveyState =
| 'ANSWERING' // Answering main questions
| 'REVIEWING_ANSWERS' // Reviewing answers
| 'EXTRA_QUESTIONS' // Answering additional questions
| 'READY_TO_SUBMIT' // Ready for submission
type SurveyAction =
| { type: 'VALIDATE_ANSWERS' } // Validate answers
| { type: 'SHOW_EXTRA_QUESTIONS' } // Show additional questions
| { type: 'SUBMIT_SURVEY' } // Submit survey
Step 2: Encapsulate State Calculation Logic
We organized complex conditions into a single pure function.
const getCurrentSurveyState = (context: SurveyContext): SurveyState => {
const { currentTab, hasMainAnswers, hasExtraAnswers, isExtraRequired } = context
if (currentTab === 'main') {
return hasMainAnswers ? 'REVIEWING_ANSWERS' : 'ANSWERING'
} else if (currentTab === 'extra') {
return hasExtraAnswers ? 'READY_TO_SUBMIT' : 'EXTRA_QUESTIONS'
}
return 'ANSWERING'
}
Step 3: Define State-Specific Configurations
We clearly defined how the form should behave in each state.
const getSurveyConfig = (state: SurveyState, context: SurveyContext): SurveyConfig => {
switch (state) {
case 'ANSWERING':
return {
disabled: !context.areMainAnswersComplete,
buttonText: 'Review Answers',
variant: 'primary',
action: { type: 'VALIDATE_ANSWERS' }
}
case 'REVIEWING_ANSWERS':
return {
disabled: false,
buttonText: 'Continue to Extra Questions',
variant: 'secondary',
action: { type: 'SHOW_EXTRA_QUESTIONS' }
}
case 'EXTRA_QUESTIONS':
return {
disabled: !context.areExtraAnswersComplete,
buttonText: 'Review Answers',
variant: 'primary',
action: { type: 'VALIDATE_ANSWERS' }
}
case 'READY_TO_SUBMIT': {
const isComplete = context.step >= context.totalSteps
return {
disabled: false,
buttonText: isComplete ? 'Submit Complete' : 'Next Step',
variant: isComplete ? 'success' : 'primary',
action: { type: 'SUBMIT_SURVEY' }
}
}
}
}
Key Improvement Points
1. Eliminating Branching with Action Map Pattern
Initially, we only returned action types from the State Machine and handled branching in the component:
// ❌ Still has branching logic
switch (surveyConfig.action.type) {
case 'VALIDATE_ANSWERS': validateAnswers(); break
case 'SHOW_EXTRA_QUESTIONS': showExtraQuestions(); break
case 'SUBMIT_SURVEY': submitSurvey(); break
}
We improved this with the Action Map pattern:
// ✅ Direct execution without branching
const actionHandlers = useMemo(() => ({
VALIDATE_ANSWERS: () => {
if (surveyState === 'ANSWERING') {
setIsReviewingMain(true)
} else if (surveyState === 'EXTRA_QUESTIONS') {
setIsReviewingExtra(true)
}
},
SHOW_EXTRA_QUESTIONS: () => {
setCurrentTab('extra')
setShowExtraQuestions(true)
collapseMainSection()
},
SUBMIT_SURVEY: () => void handleSubmit()
}), [/* optimized dependencies */])
const onNext = () => {
if (isSubmitting) return
const isValid = validateCurrentStep()
if (!isValid) return
actionHandlers[surveyConfig.action.type]() // 🎯 Simple!
}
2. Complete Encapsulation with Custom Hook
We completely separated the State Machine logic from the component:
// ✅ Component: UI only (35 lines)
export const SurveyForm = ({ questions, onComplete, maxSteps }) => {
const { isValid, validateStep } = useFormValidation()
// All logic encapsulated in State Machine Hook
const { surveyConfig, actionHandlers, isSubmitting } = useSurveyStateMachine({
questions,
maxSteps,
onComplete,
collapseMainSection,
expandSection,
})
const onNext = () => {
if (isSubmitting) return
const isStepValid = validateStep()
if (!isStepValid) return
actionHandlers[surveyConfig.action.type]()
}
return (
<div className="survey-container">
<SurveyQuestions questions={questions} />
<ActionButton
disabled={surveyConfig.disabled}
variant={surveyConfig.variant}
onClick={onNext}
>
{surveyConfig.buttonText}
</ActionButton>
</div>
)
}
// ✅ Hook: All business logic (180 lines)
export const useSurveyStateMachine = ({ questions, maxSteps, onComplete, ... }) => {
// Store access, data calculation, State Machine, Action Handlers all here
const { step, currentTab, answers, extraAnswers } = useSurveyStore(...)
const surveyContext = useMemo(() => ({
currentTab,
step,
totalSteps: maxSteps,
hasMainAnswers: answers.length > 0,
hasExtraAnswers: extraAnswers.length > 0,
areMainAnswersComplete: checkMainAnswersComplete(answers, questions),
areExtraAnswersComplete: checkExtraAnswersComplete(extraAnswers),
}), [/* dependencies */])
const surveyState = getCurrentSurveyState(surveyContext)
const surveyConfig = getSurveyConfig(surveyState, surveyContext)
return { surveyConfig, actionHandlers, isSubmitting }
}
Real-World Application Examples
E-commerce Checkout Process
type CheckoutState =
| 'CART_REVIEW' // Cart confirmation
| 'SHIPPING_INFO' // Shipping information input
| 'PAYMENT_METHOD' // Payment method selection
| 'ORDER_CONFIRMATION' // Order confirmation
const checkoutStateMachine = {
CART_REVIEW: {
nextAction: 'PROCEED_TO_SHIPPING',
buttonText: 'Enter Shipping Info',
canProceed: (context) => context.cartItems.length > 0
},
SHIPPING_INFO: {
nextAction: 'PROCEED_TO_PAYMENT',
buttonText: 'Select Payment Method',
canProceed: (context) => context.isValidShippingInfo
},
// ...
}
Game Character State Management
type PlayerState =
| 'IDLE' // Waiting
| 'MOVING' // Moving
| 'FIGHTING' // In combat
| 'DEAD' // Dead
const playerStateMachine = {
IDLE: {
availableActions: ['MOVE', 'ATTACK', 'USE_ITEM'],
restrictions: [],
animations: ['idle_breathing']
},
FIGHTING: {
availableActions: ['ATTACK', 'DEFEND', 'FLEE'],
restrictions: ['CANNOT_USE_ITEMS'],
animations: ['combat_stance']
},
// ...
}
Results and Effects
📊 Quantitative Improvements
- Lines of Code: 195 lines → 35 lines (82% reduction)
- Cyclomatic Complexity: Complex nested conditions → Clear switch statements
- Test Coverage: Independent testing possible for individual states
🎯 Qualitative Improvements
-
Readability:
isComplete
→READY_TO_SUBMIT
state makes meaning clear - Maintainability: Adding new states only requires modifying the State Machine
- Testability: Hook and Component can be tested independently
- Reusability: Same State Machine can be used in other forms
🔄 Clear State Flow
ANSWERING → REVIEWING_ANSWERS → EXTRA_QUESTIONS → READY_TO_SUBMIT
↓ ↓ ↓ ↓
Answer Validation Show Extra Answer Validation Submit Complete
Implementation Considerations
When Should You Use State Machines?
✅ Suitable Cases:
- Complex conditional logic scattered across multiple locations
- State transitions can be clearly defined
- UI state has 4 or more conditions
- Multi-step processes (onboarding, checkout, surveys, etc.)
❌ Overkill Cases:
- Simple toggle states (loading, visible, etc.)
- Independent boolean states
- Irregular or unpredictable state transitions
Performance Considerations
// ✅ Optimize state calculations with useMemo
const surveyContext = useMemo(() => ({
// context calculation
}), [/* minimal dependencies */])
// ✅ Memoize functions with useCallback
const actionHandlers = useMemo(() => ({
// action handlers
}), [/* optimized dependencies */])
Library vs Custom Implementation
Custom Implementation:
- No additional dependencies for simple logic
- Customizable to project needs
- Minimal learning burden for team members
Library Usage (XState, etc.):
- When complex state management is needed
- When visualization tools or debugging features are required
- When standardization is needed in large applications
Conclusion
The greatest achievement from applying the State Machine pattern was code predictability. We no longer need to trace conditions across multiple files to understand form behavior.
With clearly defined states and actions centralized in one place, even new team members can easily understand the code.
The key is not to blindly hide complexity, but to systematically organize it. The State Machine pattern can be a powerful tool for this purpose.
It can be particularly effective in projects like:
- 🛒 E-commerce: Cart → Checkout → Order Complete
- 📝 Onboarding Flows: Registration → Verification → Profile Setup
- 🎮 Game Logic: Character states, game progression stages
- 📊 Dashboards: Data Loading → Filtering → Results Display
If you have experience applying the State Machine pattern, please share it in the comments! It would be helpful for other developers.
Top comments (0)