Introduction

Core Concepts

StateFlow uses four key concepts that work together to provide guaranteed state consistency and explicit feedback. Understanding these concepts is essential for effective StateFlow usage.

States: Immutable Data Snapshots

States represent your application's data at a specific point in time. They are immutable snapshots with variants representing different phases:

const userState = defineState<{
  id: string;
  email: string;
  loginAttempts: number;
}>()
  .name("user")
  .variant("guest", true) // initial variant
  .variant("authenticated")
  .variant("locked")
  .stringRepr(s => `${s.email} (${s.loginAttempts} attempts)`)
  .build();

// Create state instance (immutable and frozen)
const guest = userState.guest({
  id: '',
  email: '',
  loginAttempts: 0
});
// guest.email = 'modified'; // TypeError: Cannot assign to read only property

Key Points:

  • States are frozen with Object.freeze() preventing mutations
  • Variants represent different phases (guest, authenticated, locked)
  • Each variant shares the same data structure
  • String representations aid debugging

Signals: Commands for State Changes

Signals are the only way to request state changes. They are type-safe command objects that trigger state transitions:

const userSignals = {
  // Simple parameterless signal
  logout: defineSignal("logout"),
  
  // Parameterized signal with validation
  login: defineSignal<{ email: string; password: string }>("login"),
  
  // Complex signal with multiple parameters
  updateProfile: defineSignal<{ 
    name?: string; 
    email?: string; 
    avatar?: string 
  }>("updateProfile")
};

// Usage: Create signal instances
const loginSignal = userSignals.login({ 
  email: 'user@example.com', 
  password: 'secret' 
});

Key Points:

  • Signals centralize all state change requests
  • Type-safe parameters prevent runtime errors
  • Immutable command objects with unique identifiers

Flows: Pure State Transition Logic

Flows define how state variants respond to signals. They are pure functions that return new states or Results:

defineFlow(userState.guest, {
  login: (state, signal) => {
    // Validation logic
    if (!signal.email || !signal.password) {
      return Result.reject('Email and password required');
    }
    
    // State transition
    return userState.authenticated({
      id: generateId(),
      email: signal.email,
      loginAttempts: 0
    });
  }
});

defineFlow(userState.authenticated, {
  logout: (state) => userState.guest({
    id: '',
    email: '',
    loginAttempts: state.loginAttempts
  }),
  
  updateProfile: (state, signal) => ({
    ...state,
    email: signal.email || state.email
  })
});

Flow Rules:

  • Pure functions only (no side effects)
  • Must return synchronously
  • Each variant has one flow definition
  • Unhandled signals are ignored

Results: Explicit Operation Feedback

Every signal dispatch returns a Result that explicitly indicates what happened, eliminating silent failures:

// Modern pattern - use .expect() and .done()
try {
  await dispatch(app, userSignals.login({ email, password }))
    .expect(ResultKind.OK)
    .done();
  console.log('Login successful');
} catch (error) {
  console.error('Login failed:', error);
}

// Result types returned by flows:
// Result.ok() - Success
// Result.reject('message') - Validation failed  
// Result.error(error) - Exception occurred
// Result.ignore('reason') - Signal not applicable
// Result.transition(asyncFn, timeout) - Async operation (state handlers only)

Key Points:

  • Results provide explicit feedback for every operation
  • Use .expect() to validate expected result types
  • Use .done() to handle both sync and async operations

Application Integration

Connect StateFlow to your application using applyFlow for side effects and resource management:

const app = {
  user: { id: '', email: '', loginAttempts: 0 },
  connection: { status: 'disconnected', url: '' }
};

applyFlow(app, [userState, connectionState], (sm) => {
  // Side effects when entering states
  sm.addEnterHandler(userState.authenticated, (state) => {
    localStorage.setItem('userId', state.id);
    return Result.ok();
  });
  
  // Async operations with timeout
  sm.addEnterHandler(connectionState.connecting, (state) => {
    return Result.transition(async () => {
      await connectToServer(state.url);
      return Result.ok();
    }, 5000);
  });
  
  // Cleanup when leaving states
  sm.addExitHandler(userState.authenticated, (state) => {
    localStorage.removeItem('userId');
    return Result.ok();
  });
});

Handler Types:

  • addEnterHandler - Called when entering a state
  • addExitHandler - Called when leaving a state
  • addUpdateHandler - Called when state data changes
  • addRollbackHandler - Called when transitions fail

Observability: State Monitoring

StateFlow provides built-in mechanisms for monitoring state changes:

// Observe specific state variants
const subscription = observe(
  app,
  [connectionState.connected, connectionState.failed],
  (state) => {
    updateUIConnectionStatus(state);
  }
);

// Custom comparison for fine-grained updates
observe(
  app,
  [connectionState.connecting],
  (state) => updateRetryCounter(state.retryCount),
  (prev, curr) => prev.retryCount !== curr.retryCount
);

// Cleanup when done
subscription[Symbol.dispose]();

These concepts work together to create a state management system that is predictable, debuggable, and type-safe, while providing explicit feedback for every operation.

Previous
Getting started