State Management

State Consistency

StateFlow prevents state inconsistency through a combination of compile-time type safety and runtime immutability enforcement. This dual-layer protection makes it virtually impossible to create invalid or corrupted states in your application.

The Consistency Problem

Traditional state management systems suffer from several consistency issues that StateFlow eliminates by design.

Race Conditions

In mutable state systems, concurrent modifications can lead to race conditions where the final state depends on the order of operations rather than business logic. StateFlow prevents this through immutability and atomic state transitions. Every state change creates a new immutable snapshot, ensuring that concurrent operations cannot interfere with each other.

Partial Updates

Mutable systems often allow partial state updates that can leave the application in an inconsistent state if an error occurs mid-update. StateFlow requires complete state objects for every transition, making partial updates impossible. If a transition fails, the original state remains unchanged.

Type Violations

JavaScript's dynamic nature allows runtime type violations that can corrupt state. StateFlow leverages TypeScript's type system at compile time and enforces structure at runtime, creating a robust barrier against type-related inconsistencies.

Immutability Enforcement

StateFlow enforces immutability at multiple levels to ensure state consistency.

Runtime Freezing

Every state instance is frozen using Object.freeze() upon creation:

const state = playbackState.playing({ 
  position: 30, 
  duration: 180 
});

// Runtime error - state is frozen
state.position = 45; // TypeError: Cannot assign to read only property

State instances are shallow-frozen via Object.freeze(); top-level props cannot be reassigned, but nested objects/arrays are not recursively frozen.

Type-Level Immutability

TypeScript's type system provides compile-time protection:

type PlaybackState = {
  readonly position: number;
  readonly duration: number;
};

// Compile error - cannot assign to readonly property
const updatePosition = (state: PlaybackState) => {
  state.position = 45; // Error: Cannot assign to 'position' because it is a read-only property
};

Immutable Transitions

State transitions always create new instances rather than modifying existing ones:

defineFlow(playbackState.playing, {
  seek: (state, signal) => ({
    ...state, // Spread existing state
    position: signal.position // Override specific field
  })
});

// Original state remains unchanged
const before = playbackState.playing({ position: 30, duration: 180 });
await using send = await lock(app);
await send(signals.seek({ position: 60 })).done();
console.log(before.position); // Still 30 - original unchanged

Atomic State Transitions

StateFlow ensures that state transitions are atomic operations that either complete fully or fail without side effects.

Transaction Boundaries

Each dispatch operation forms a transaction boundary. Within this boundary, all state changes are collected and validated before being applied:

// Multiple states change atomically
await using send = await lock(app);
const result = send(signals.startPlayback());

if (result.kind === ResultKind.OK) {
  // All states transitioned successfully
  // playbackState: paused -> playing
  // bufferState: empty -> buffering
  // uiState: idle -> active
} else {
  // No states changed - all remain in original state
}

Rollback Mechanism

When a transition fails, StateFlow provides rollback handlers to ensure consistency:

applyFlow(app, [playbackState], (sm) => {
  sm.addEnterHandler(playbackState.playing, (state) => {
    // Async work must be wrapped in Result.transition() — handlers
    // return a Result synchronously, never a Promise.
    return Result.transition(async () => {
      const result = await startMediaPlayback();
      if (!result.success) {
        return Result.reject("Playback initialization failed");
      }
      return Result.ok();
    });
  });

  sm.addRollbackHandler(playbackState.playing, (state) => {
    // Clean up any partial initialization
    stopMediaResources();
    return Result.ok();
  });
});

Enqueue Chain Rollback

When using Result.enqueue() to chain signals, the entire chain is atomic. If any enqueued signal fails, all state changes — including the original dispatch — are rolled back to the state before the chain started:

sm.addEnterHandler(orderState.confirmed, () => {
  return Result.enqueue(signals.chargePayment());
});

// If chargePayment fails:
// 1. orderState rolls back to its pre-dispatch state
// 2. All intermediate state changes are undone
// 3. The error result from the failed signal is returned

Type Safety Through Inference

StateFlow leverages TypeScript's type inference to maintain consistency without verbose type annotations.

State Type Inference

The Infer utility type extracts precise types from state definitions:

const connectionState = defineState<{
  url: string;
  status: 'connecting' | 'connected' | 'error';
  errorCount: number;
}>()
  .name("connection")
  .signals(signals)
  .variant("active", true)
  .build();

// Type is automatically inferred
type ConnectionProps = Infer<typeof connectionState.active>;
// { url: string; status: 'connecting' | 'connected' | 'error'; errorCount: number }

// Application structure is type-checked
interface App {
  connection: ConnectionProps;
}

Signal Type Safety

Signals maintain type safety for their parameters:

const signals = {
  updateConfig: defineSignal<{
    timeout: number;
    retryLimit: number;
  }>("updateConfig")
};

await using send = await lock(app);

// Type error - missing required field
send(signals.updateConfig({ timeout: 5000 }));
// Error: Property 'retryLimit' is missing

// Type error - wrong type
send(signals.updateConfig({ 
  timeout: "5000", // Error: Type 'string' is not assignable to type 'number'
  retryLimit: 3 
}));

Validation at Transition Time

StateFlow allows validation logic within flow definitions to ensure business rule consistency.

Input Validation

Flow handlers can validate inputs and reject invalid transitions:

defineFlow(audioState.playing, {
  setVolume: (state, signal) => {
    if (signal.level < 0 || signal.level > 1) {
      return Result.reject("Volume must be between 0 and 1");
    }
    
    if (state.muted && signal.level > 0) {
      return Result.reject("Cannot set volume while muted");
    }
    
    return { ...state, volume: signal.level };
  }
});

State Invariants

Complex invariants can be enforced across multiple state properties:

defineFlow(gameState.playing, {
  movePlayer: (state, signal) => {
    const newPosition = {
      x: state.player.x + signal.deltaX,
      y: state.player.y + signal.deltaY
    };
    
    // Enforce boundary constraints
    if (newPosition.x < 0 || newPosition.x > state.boardSize ||
        newPosition.y < 0 || newPosition.y > state.boardSize) {
      return Result.reject("Move would place player outside board");
    }
    
    // Check collision with obstacles
    const collision = state.obstacles.some(
      obs => obs.x === newPosition.x && obs.y === newPosition.y
    );
    
    if (collision) {
      return Result.reject("Move blocked by obstacle");
    }
    
    return {
      ...state,
      player: newPosition,
      moveCount: state.moveCount + 1
    };
  }
});

Preventing Common Inconsistencies

StateFlow's design prevents several common state inconsistency patterns.

No Forgotten Updates

Traditional systems often require updating multiple related states, leading to inconsistencies when developers forget some updates. StateFlow's atomic transitions ensure all related states update together:

defineFlow(orderState.pending, {
  confirmOrder: (state) => {
    // All related states must be returned together
    return orderState.confirmed({
      ...state,
      confirmedAt: Date.now(),
      status: 'confirmed',
      // Compiler ensures all required fields are provided
    });
  }
});

No Stale Closures

JavaScript closures can capture stale state values. StateFlow's signal-based architecture ensures handlers always receive the current state:

// ❌ Traditional approach - prone to stale closure
let count = 0;
setTimeout(() => {
  count++; // Might use stale value
}, 1000);

// ✅ StateFlow approach - always current
defineFlow(counterState.active, {
  increment: (state) => ({ count: state.count + 1 })
  // 'state' parameter is always the current state
});

No Circular Dependencies

StateFlow's unidirectional data flow prevents circular state dependencies:

// States can only be modified through signals
// This prevents State A from directly modifying State B
// which could then modify State A, creating a cycle

defineFlow(stateA.active, {
  update: (state, signal, context) => {
    // Can read other states from context
    const stateB = context.stateB;
    
    // But cannot directly modify them
    // Must return new state for A only
    return { ...state, value: stateB.value + 1 };
  }
});

Through these mechanisms, StateFlow provides strong guarantees about state consistency, making it suitable for applications where correctness is critical.

Previous
Installation