State Handlers

State-based Code Execution

State handlers form the bridge between process definitions and executable code within the StateWalker FSM orchestrator framework.

Handers are simple functions associating specific actions with individual process stages, executing when the state machine enters a particular state and optionally providing cleanup functionality when transitioning away.

There are different types of handlers by their functional responsibilities within the application architecture. While structurally identical they fulfill distinct roles:

  • Views → Presentation layer and user interaction
  • Controllers → Business logic coordination and API integration
  • Triggers → Event generation for state transitions
type ApplicationContext = Record<string, unknown>;
type StateHandler = (context: ApplicationContext) =>
  | void                                         // No cleanup needed
  | (() => void | Promise<void>)                 // Sync/async cleanup function
  | Promise<void | (() => void | Promise<void>)> // Promise-based execution
  | AsyncGenerator<string>                       // Special case: triggers

type StateKey = string;
type StateHandlerKey<Key extends StateKey = StateKey> =
  | Key
  | `${Key}Controller`
  | `${Key}StateController`
  | `${Key}Trigger`
  | `${Key}StateTrigger`
  | `${Key}View`
  | `${Key}StateView`;

// State handlers to export from an application module
type StateHandlers =
  // A handler to call on each state
  | StateHandler
  | ({
      // Optional root state handler to call on the root state
      default?: StateHandler;
    } & {
      // State-specific handlers
      [key: StateHandlerKey]: StateHandler;
    });

Each handler receives the shared process context, enabling access to data models, APIs, and services while maintaining loose coupling through the dependency injection pattern.

The handler architecture embraces simplicity through its functional approach, where each handler is merely a function that can perform initialization work and return a cleanup function for resource management. This design eliminates the need for complex class hierarchies or framework-specific base classes, allowing developers to focus on business logic rather than infrastructure concerns. Handlers can be synchronous or asynchronous, and can range from simple state notifications to complex orchestration of multiple services and data models.

Convention-Based State Binding

The FSM orchestrator employs naming conventions to automatically bind handlers to their corresponding states, eliminating the need for explicit configuration while maintaining clear relationships between process definitions and implementation code. For any given state with key StateName, the orchestrator will search for and execute multiple handler functions following predictable naming patterns.

Valid handler names for the “EditDocument” state include:

This convention-driven approach enables automatic handler discovery and binding, allowing clean separation of concerns where each handler focuses on its specific responsibility while sharing the same process context and state lifecycle.

Implementation Patterns and Examples

Simple Handlers

The simplest handler implementations require no context usage and perform basic actions without cleanup requirements, making them ideal for simple state notifications or one-time operations.

// Simple notification handler
function ProcessingStarted() {
  console.log('Data processing has begun');
}

// The simplest handler with no operation
function SimpleHandler() {}

// A sync handler returning a cleanup function
function MyHandler(context: Record<string, unknown>) {
  console.log('Start process.');
  return () => console.log('Stop process.');
}

Resource-Aware Handlers

More sophisticated handlers leverage the process context to access shared resources and implement proper resource management through cleanup functions that execute when the state machine transitions away from the current state.

// Resource-aware handler with cleanup
function DatabaseConnection(context: ProcessContext) {
  const database = getDatabaseApi(context);
  const connection = database.connect();
  
  console.log('Database connection established');
  
  return () => {
    connection.close();
    console.log('Database connection closed');
  };
}

Complex Reactive Handlers

Advanced implementations showcase handlers that coordinate multiple models and APIs, demonstrating reactive patterns and comprehensive resource management.

// Asynchronous handler with complex logic
async function SearchOperation(context: ProcessContext) {
  const [register, cleanup] = newRegistry();
  const searchFormModel = getSearchFormModel(context);
  const searchResultsModel = getSearchResultsModel(context);
  const searchSuggestionsModel = getSearchSuggestionsModel(context);
  const searchApi = getSearchApi(context);
  
  // Reset all models to initial state
  searchFormModel.reset();
  searchResultsModel.reset();
  searchSuggestionsModel.reset();
  
  let previousQuery = "";
  
  // Register reactive listener for form changes
  register(searchFormModel.onChange(async () => {
    const currentQuery = searchFormModel.query;
    
    if (searchFormModel.isSubmitted()) {
      searchResultsModel.loading = true;
      try {
        const results = await searchApi.searchResults({ query: currentQuery });
        searchResultsModel.items = results;
      } catch (error) {
        searchResultsModel.error = error;
      } finally {
        searchResultsModel.loading = false;
        searchFormModel.reset();
        searchSuggestionsModel.reset();
      }
    } else if (previousQuery !== currentQuery) {
      previousQuery = currentQuery;
      searchSuggestionsModel.loading = true;
      try {
        const suggestions = await searchApi.loadSuggestions({ query: currentQuery });
        searchSuggestionsModel.items = suggestions;
      } catch (error) {
        searchSuggestionsModel.error = error;
      } finally {
        searchSuggestionsModel.loading = false;
      }
    }
  }));
  
  return cleanup;
}

Context Integration and Resource Access

All state handlers receive a single parameter - the process context object used to share common information between handlers. This shared information includes common data models, APIs, services, and configurations accessed through the adapter pattern.

async function LoginController(context: ProcessContext) {
  const [register, cleanup] = newRegistry(); // The registry pattern

  const loginForm = getLoginForm(context); // Get the form using adapters pattern
  loginForm.reset();

  const loginApi = getLoginApi(context); // Get the API using adapters pattern

  const unsubscribe = loginForm.onChange(async () => {
    if (loginForm.isSubmitted()) {
      const credentials = loginForm.getCredentials();
      const result = await loginApi.login(credentials);
      // Handle login result...
    }
  });
  register(unsubscribe);

  return cleanup;
}

The context serves as a lightweight dependency injection mechanism, allowing handlers to access necessary resources without creating direct dependencies while maintaining the framework’s zero-dependency philosophy.

Coffee Machine State Handler Examples

Using the coffee machine process as a practical example, different handler types work together to implement complete state functionality.

Coffee Machine Controller

async function PrepareDrinkController(context: ProcessContext) {
  const api = getCoffeeMachineApi(context);
  const model = getCoffeeMachineModel(context);
  
  // Set state when entering
  model.preparingCoffee = true;
  
  // Start async interaction with the remote API
  (async () => {
    await api.heatWater();
    await api.brewCoffee();
    model.preparingCoffee = false;
  })();
  
  return () => {
    // Clean up resources when exiting state
  };
}

Coffee Machine View

async function PrepareDrinkView(context: ProcessContext) {
  const element = document.querySelector("#coffee-machine-status");
  const model = getCoffeeMachineModel(context);
  
  function renderStatus() {
    element.innerHTML = model.preparingCoffee
      ? `Preparing coffee... (${model.currentTemperature}°C)`
      : "Ready";
  }
  
  // Re-render on model changes
  const cleanup = model.onChange(renderStatus);
  renderStatus(); // Initial render

  return cleanup; // Cancel subscription on exit
}

Coffee Machine Trigger

async function* PrepareDrinkTrigger(context: ProcessContext): AsyncGenerator<string> {
  const model = getCoffeeMachineModel(context);
  
  yield* newAsyncGenerator((notify) => {
    return model.onChange(() => {
      if (!model.preparingCoffee) {
        notify("done");
      }
    });
  });
}

Handler Lifecycle and State Transitions

State handlers start sequentially when the system enters their corresponding state. Controllers, Views, and Triggers associated with the same state all receive the same process context and share the state lifecycle. Returned cleanup methods are called when exiting the state, ensuring proper resource management.

The handler execution follows this pattern:

  1. State entry triggers handler execution
  2. Handler performs initialization and sets up subscriptions
  3. Handler returns cleanup function (optional)
  4. State exit triggers cleanup function execution
  5. Resources are properly released

Architectural Benefits and Design Principles

Separation of Concerns

This granular approach to state handlers promotes clear separation between different application aspects: data management, external API interactions, data visualization, and transition triggering. While you could implement all operations in one handler, monolithic implementation creates tight coupling that makes testing, integration, and isolation difficult.

Independent Operation

Controllers, Triggers, and Views are completely independent of one another, enabling selective activation where individual handlers can be enabled or disabled as needed. This independence facilitates simplified testing where controllers can be tested with mock APIs while views use demo data.

Convention Over Configuration

Handlers are bound to states based on their names, eliminating explicit configuration while maintaining clear relationships. The framework pilots code from outside based on process configuration without introducing dependencies in user code.

Flexible Deployment

The modular nature supports flexible deployment scenarios - visual demos can run without API calls or heavy operations, while production systems can include full controller functionality.

This handler architecture enables complex orchestration scenarios while maintaining code clarity and testability. Each handler can focus on its specific concerns while relying on the shared context for resource access and the registry pattern for proper cleanup management. The combination of convention-based binding and functional design creates a system that scales naturally from simple state notifications to sophisticated multi-service orchestration without sacrificing maintainability or developer experience.