DEV Community

youngrok
youngrok

Posted on

Refactoring Complex Conditional Logic with State Machines: Online Survey Form Improvement Case Study

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
Enter fullscreen mode Exit fullscreen mode

Vending Machine:

States: [Idle, CoinInserted, ProductSelected, DispensingChange]
Transitions: Idle → CoinInserted → ProductSelected → DispensingChange → Idle
Enter fullscreen mode Exit fullscreen mode

Core Principles

  1. One State at a Time: A traffic light cannot be both red and green simultaneously
  2. Explicit Transitions: States only change under defined conditions
  3. 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!
Enter fullscreen mode Exit fullscreen mode

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:

  1. Question Answering: Users respond to survey questions
  2. Answer Review: Users confirm their responses
  3. Additional Questions: Conditional advanced questions appear
  4. 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()
  }
}
Enter fullscreen mode Exit fullscreen mode

Problems with this code:

  1. Poor Readability: Hard to understand if isComplete truly means completion
  2. Difficult Maintenance: Adding new steps requires modifying multiple places
  3. Testing Complexity: Difficult to test all condition combinations
  4. 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
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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' }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode
// ✅ 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 }
}
Enter fullscreen mode Exit fullscreen mode

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
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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']
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Readability: isCompleteREADY_TO_SUBMIT state makes meaning clear
  2. Maintainability: Adding new states only requires modifying the State Machine
  3. Testability: Hook and Component can be tested independently
  4. 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
Enter fullscreen mode Exit fullscreen mode

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 */])
Enter fullscreen mode Exit fullscreen mode

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)