Skip to main content

7 min read

Migrating from XState

Migrate your XState state machines to Directive.


Key Differences

XStateDirective
States + TransitionsFacts (direct values)
GuardsConstraints (when conditions)
ActionsEffects + Resolvers
ServicesResolvers (async)
ContextFacts

When to Migrate

Keep XState when:

  • UI flows with strict state sequences (wizards, forms)
  • Finite state modeling is the core abstraction
  • Visualizing state machines is important

Use Directive when:

  • Data-driven constraints ("if X, then Y must happen")
  • Complex dependency graphs
  • Automatic resolution of requirements
  • Less boilerplate for common patterns

Before: XState Traffic Light

// Before: XState approach – define states, transitions, and timed events
import { createMachine, interpret } from 'xstate';

const trafficLightMachine = createMachine({
  id: 'trafficLight',
  initial: 'red',
  context: { elapsed: 0 },

  // Each state defines its own transitions and timing
  states: {
    red: {
      after: { 30000: 'green' },   // Auto-transition after 30s
      entry: 'resetTimer',          // Run action on state entry
    },
    green: {
      after: { 25000: 'yellow' },  // Auto-transition after 25s
      entry: 'resetTimer',
    },
    yellow: {
      after: { 5000: 'red' },      // Auto-transition after 5s
      entry: 'resetTimer',
    },
  },
}, {
  // Actions are defined separately and referenced by name
  actions: {
    resetTimer: (context) => { context.elapsed = 0 },
  },
});

// Interpret the machine to create a running service
const service = interpret(trafficLightMachine).start();

After: Directive Traffic Light

// After: Directive approach – constraints declare when transitions should happen
import { createModule, createSystem, t } from '@directive-run/core';

const trafficLightModule = createModule("traffic-light", {
  schema: {
    facts: {
      phase: t.string<'red' | 'green' | 'yellow'>(),
      elapsed: t.number(),
    },
  },

  init: (facts) => {
    facts.phase = 'red';
    facts.elapsed = 0;
  },

  // Each constraint declares a condition that triggers a transition
  constraints: {
    redToGreen: {
      when: (facts) => facts.phase === 'red' && facts.elapsed >= 30,
      require: { type: 'TRANSITION', to: 'green' },
    },

    greenToYellow: {
      when: (facts) => facts.phase === 'green' && facts.elapsed >= 25,
      require: { type: 'TRANSITION', to: 'yellow' },
    },

    yellowToRed: {
      when: (facts) => facts.phase === 'yellow' && facts.elapsed >= 5,
      require: { type: 'TRANSITION', to: 'red' },
    },
  },

  // One resolver handles all transitions – the requirement carries the data
  resolvers: {
    transition: {
      requirement: 'TRANSITION',
      resolve: (req, context) => {
        context.facts.phase = req.to;
        context.facts.elapsed = 0;
      },
    },
  },
});

Migrating Guards to Constraints

XState Guards

// Before: XState guards – conditions that gate transitions between states
const paymentMachine = createMachine({
  states: {
    idle: {
      on: {
        SUBMIT: {
          target: 'processing',
          cond: 'isValidAmount',   // Guard blocks transition if false
        },
      },
    },

    processing: {
      // Invoke an async service, route to success or failure state
      invoke: {
        src: 'processPayment',
        onDone: 'success',
        onError: 'failed',
      },
    },
  },
}, {
  // Guards are defined separately and referenced by string name
  guards: {
    isValidAmount: (context) => context.amount > 0,
  },
});

Directive Constraints

// After: Directive constraints – guard logic and trigger logic live together
const paymentModule = createModule("payment", {
  schema: {
    facts: {
      amount: t.number(),
      status: t.string<'idle' | 'processing' | 'success' | 'failed'>(),
      shouldSubmit: t.boolean(),
    },
  },

  constraints: {
    processPayment: {
      // All conditions inline – combines guard + trigger in one place
      when: (facts) =>
        facts.shouldSubmit &&
        facts.amount > 0 &&
        facts.status === 'idle',
      require: { type: 'PROCESS_PAYMENT' },
    },
  },

  resolvers: {
    processPayment: {
      requirement: 'PROCESS_PAYMENT',
      resolve: async (req, context) => {
        context.facts.status = 'processing';

        try {
          await api.processPayment(context.facts.amount);
          context.facts.status = 'success';
        } catch {
          context.facts.status = 'failed';
        }

        // Reset the trigger flag after processing
        context.facts.shouldSubmit = false;
      },
    },
  },
});

Migrating Services to Resolvers

XState Services

// Before: XState invoked service – async work tied to a specific state
const userMachine = createMachine({
  states: {
    loading: {
      // Invoke runs when entering this state
      invoke: {
        id: 'fetchUser',
        src: (context) => fetchUser(context.userId),

        // Route to different states based on outcome
        onDone: {
          target: 'loaded',
          actions: assign({ user: (_, event) => event.data }),
        },
        onError: {
          target: 'error',
          actions: assign({ error: (_, event) => event.data }),
        },
      },
    },
  },
});

Directive Resolvers

// After: Directive resolver – constraint triggers fetch, resolver handles async + retry
const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.number(),
      user: t.object<User>().nullable(),
      error: t.string().nullable(),
      loading: t.boolean(),
    },
  },

  // Constraint declares when user data is needed
  constraints: {
    needsUser: {
      when: (facts) => facts.userId > 0 && !facts.user && !facts.loading,
      require: { type: 'FETCH_USER' },
    },
  },

  // Resolver fulfills the requirement with built-in retry support
  resolvers: {
    fetchUser: {
      requirement: 'FETCH_USER',
      retry: { attempts: 3, backoff: 'exponential' }, // Automatic retry on failure

      resolve: async (req, context) => {
        context.facts.loading = true;

        try {
          context.facts.user = await fetchUser(context.facts.userId);
          context.facts.error = null;
        } catch (e) {
          context.facts.error = e.message;
        } finally {
          context.facts.loading = false;
        }
      },
    },
  },
});

Migrating Actions to Effects

XState Actions

// Before: XState entry/exit actions – side effects tied to state transitions
const formMachine = createMachine({
  states: {
    editing: {
      entry: 'focusInput',     // Run when entering this state
      exit: 'validateForm',    // Run when leaving this state
      on: { SUBMIT: 'submitting' },
    },
  },
}, {
  // Actions defined separately and referenced by name
  actions: {
    focusInput: () => document.getElementById('input')?.focus(),
    validateForm: (context) => console.log('Validating...'),
  },
});

Directive Effects

// After: Directive effects – react to fact changes, not state transitions
const formModule = createModule("form", {
  schema: {
    facts: {
      status: t.string<'editing' | 'submitting'>(),
    },
  },

  effects: {
    // Auto-tracks `status` – fires whenever it changes, replaces XState's entry action
    onEditing: {
      run: (facts) => {
        if (facts.status === 'editing') {
          document.getElementById('input')?.focus();
        }
      },
    },

    // Compare previous and current values – replaces XState's exit action
    onSubmit: {
      run: (facts, prev) => {
        if (prev?.status === 'editing' && facts.status === 'submitting') {
          console.log('Validating...');
        }
      },
    },
  },
});

Hierarchical States to Flat Facts

XState Nested States

// Before: XState hierarchical states – nested state trees model compound conditions
const machine = createMachine({
  states: {
    authenticated: {
      initial: 'idle',
      // Child states only accessible when authenticated
      states: {
        idle: {},
        loading: {},
        error: {},
      },
    },

    unauthenticated: {},
  },
});

Directive Flat Facts

// After: Directive – flat independent facts, derive compound state when needed
const authModule = createModule("auth", {
  schema: {
    facts: {
      // Two independent facts instead of a nested state tree
      isAuthenticated: t.boolean(),
      dataStatus: t.string<'idle' | 'loading' | 'error'>(),
    },
  },

  derive: {
    // Reconstruct the compound state from flat facts if needed
    currentState: (facts) => {
      if (!facts.isAuthenticated) {
        return 'unauthenticated';
      }

      return `authenticated.${facts.dataStatus}`;
    },
  },
});

Parallel States to Independent Facts

XState Parallel States

// Before: XState parallel states – two independent state machines running simultaneously
const machine = createMachine({
  type: 'parallel',  // Both regions active at the same time

  states: {
    // Upload region with its own state flow
    upload: {
      initial: 'idle',
      states: { idle: {}, uploading: {}, complete: {} },
    },

    // Validation region runs independently of upload
    validation: {
      initial: 'pending',
      states: { pending: {}, valid: {}, invalid: {} },
    },
  },
});

Directive Independent Facts

// After: Directive – facts are naturally independent, no parallel config needed
const fileModule = createModule("file", {
  schema: {
    facts: {
      // Each fact is independent – no need for parallel state regions
      uploadStatus: t.string<'idle' | 'uploading' | 'complete'>(),
      validationStatus: t.string<'pending' | 'valid' | 'invalid'>(),
    },
  },

  // Each constraint operates on its own facts – they resolve independently
  constraints: {
    needsUpload: {
      when: (facts) => facts.uploadStatus === 'idle',
      require: { type: 'UPLOAD' },
    },

    needsValidation: {
      when: (facts) => facts.validationStatus === 'pending',
      require: { type: 'VALIDATE' },
    },
  },
});

Next Steps

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven State Management for TypeScript