Examples

Media Player Example

This comprehensive example demonstrates how to build a fully-featured media player using StateFlow. The implementation showcases state consistency guarantees, signal handling with comprehensive feedback, and clear state visibility throughout the application lifecycle.

Overview

The media player implementation demonstrates several key StateFlow concepts working together in a real-world scenario. The player manages multiple interconnected states including playback control, volume management, buffering status, and error handling. Each state transition is carefully validated, side effects are properly managed, and the entire system maintains consistency even during complex operations like seeking or handling network interruptions.

State Architecture

The media player consists of four primary state definitions that work together to manage the complete player functionality.

Snippets are shown out of execution order

The state definitions below reference mediaSignals via .signals(mediaSignals), but mediaSignals is defined in the Signal Definitions section further down. In real code mediaSignals must be declared before any state that wires it in — otherwise you hit a temporal dead zone (Cannot access 'mediaSignals' before initialization). Treat the Signal Definitions block as appearing first.

Playback State

The playback state manages the core player lifecycle, tracking position, duration, and playback status. Each variant represents a distinct phase in the media lifecycle, from initial loading through active playback to completion.

const playbackState = defineState<{
  position: number;      // Current playback position in seconds
  duration: number;      // Total media duration in seconds
  playbackRate: number;  // Playback speed multiplier
  mediaUrl?: string;     // Currently loaded media URL
  lastError?: Error;     // Most recent error if any
}>()
  .name("playback")
  .signals(mediaSignals)
  .variant("idle", true)
  .variant("loading")
  .variant("ready")
  .variant("playing")
  .variant("paused")
  .variant("ended")
  .variant("error")
  .stringRepr(state => {
    const base = `${state.position.toFixed(1)}/${state.duration.toFixed(1)}s`;
    if (state.playbackRate !== 1) {
      return `${base} @${state.playbackRate}x`;
    }
    if (state.lastError) {
      return `${base} - Error: ${state.lastError.message}`;
    }
    return base;
  })
  .build();

Volume State

Volume management includes mute functionality with memory of previous volume levels, enabling smooth user experience when toggling mute status.

const volumeState = defineState<{
  level: number;        // Volume level 0.0 to 1.0
  muted: boolean;       // Current mute status
  previousLevel: number; // Remembered level for unmute
}>()
  .name("volume")
  .signals(mediaSignals)
  .variant("audible", true)
  .variant("muted")
  .stringRepr(state => 
    state.muted ? "muted" : `${Math.round(state.level * 100)}%`
  )
  .build();

Buffer State

Buffer state tracks media loading progress and readiness, providing visibility into network operations and enabling responsive UI updates.

const bufferState = defineState<{
  bufferedRanges: Array<{ start: number; end: number }>;
  isBuffering: boolean;
  bufferHealth: number; // 0.0 to 1.0 indicating buffer sufficiency
}>()
  .name("buffer")
  .signals(mediaSignals)
  .variant("empty", true)
  .variant("buffering")
  .variant("sufficient")
  .variant("starving")
  .stringRepr(state => {
    const total = state.bufferedRanges.reduce(
      (sum, range) => sum + (range.end - range.start), 0
    );
    return `${total.toFixed(1)}s buffered (health: ${state.bufferHealth.toFixed(2)})`;
  })
  .build();

UI State

UI state manages the player interface visibility and interaction modes, ensuring consistent user experience across different player states.

const uiState = defineState<{
  controlsVisible: boolean;
  seekPreview?: number;    // Preview position during seek
  volumeSliderVisible: boolean;
  lastInteraction: number; // Timestamp of last user interaction
}>()
  .name("ui")
  .signals(mediaSignals)
  .variant("hidden", true)
  .variant("visible")
  .variant("seeking")
  .variant("adjustingVolume")
  .stringRepr(state => 
    state.controlsVisible ? "controls visible" : "controls hidden"
  )
  .build();

Signal Definitions

Signals provide the controlled interface for all state modifications. Each signal is carefully designed to carry the necessary information while maintaining type safety.

const mediaSignals = {
  // Media lifecycle signals
  load: defineSignal<{
    url: string;
    autoplay?: boolean;
  }>("load"),
  
  play: defineSignal("play"),
  pause: defineSignal("pause"),
  stop: defineSignal("stop"),
  
  // Playback control signals
  seek: defineSignal<{
    position: number;
    preview?: boolean; // True for preview during drag
  }>("seek"),
  
  setPlaybackRate: defineSignal<{
    rate: number;
  }>("setPlaybackRate"),
  
  // Volume control signals
  setVolume: defineSignal<{
    level: number;
  }>("setVolume"),
  
  mute: defineSignal("mute"),
  unmute: defineSignal("unmute"),
  toggleMute: defineSignal("toggleMute"),
  
  // Buffer management signals
  bufferUpdate: defineSignal<{
    ranges: Array<{ start: number; end: number }>;
    health: number;
  }>("bufferUpdate"),
  
  // UI interaction signals
  showControls: defineSignal("showControls"),
  hideControls: defineSignal("hideControls"),
  startSeeking: defineSignal("startSeeking"),
  endSeeking: defineSignal("endSeeking"),
  
  // Error handling signals
  handleError: defineSignal<{
    error: Error;
    recoverable: boolean;
  }>("handleError"),
  
  retry: defineSignal("retry"),
  
  // Progress update signal
  timeUpdate: defineSignal<{
    position: number;
    buffered: Array<{ start: number; end: number }>;
  }>("timeUpdate")
};

State Flows

State flows define the valid transitions and business logic for each state variant. These flows ensure that the player behaves correctly in all situations.

Playback State Flows

defineFlow(playbackState.idle, {
  load: (state, signal) => playbackState.loading({
    ...state,
    mediaUrl: signal.url,
    position: 0,
    duration: 0,
    lastError: undefined
  }),
  
  play: () => Result.reject("No media loaded"),
  seek: () => Result.reject("No media loaded")
});

defineFlow(playbackState.loading, {
  handleError: (state, signal) => playbackState.error({
    ...state,
    lastError: signal.error
  }),
  
  bufferUpdate: (state, signal) => {
    // Transition to ready when we have initial buffer
    if (signal.health > 0.2 && signal.ranges.length > 0) {
      const duration = Math.max(...signal.ranges.map(r => r.end));
      return playbackState.ready({
        ...state,
        duration,
        position: 0
      });
    }
    return Result.ignore("Insufficient buffer");
  }
});

defineFlow(playbackState.ready, {
  play: (state) => playbackState.playing(state),
  
  seek: (state, signal) => {
    if (signal.position < 0 || signal.position > state.duration) {
      return Result.reject(`Invalid seek position: ${signal.position}`);
    }
    return { ...state, position: signal.position };
  }
});

defineFlow(playbackState.playing, {
  pause: (state) => playbackState.paused(state),
  
  stop: () => playbackState.idle({
    position: 0,
    duration: 0,
    playbackRate: 1,
    mediaUrl: undefined
  }),
  
  timeUpdate: (state, signal) => {
    const newState = { ...state, position: signal.position };
    
    // Check if playback has ended
    if (signal.position >= state.duration - 0.1) {
      return playbackState.ended(newState);
    }
    
    return newState;
  },
  
  seek: (state, signal) => {
    if (signal.position < 0 || signal.position > state.duration) {
      return Result.reject("Seek position out of range");
    }
    return { ...state, position: signal.position };
  },
  
  setPlaybackRate: (state, signal) => {
    if (signal.rate < 0.25 || signal.rate > 4.0) {
      return Result.reject("Playback rate must be between 0.25 and 4.0");
    }
    return { ...state, playbackRate: signal.rate };
  }
});

defineFlow(playbackState.error, {
  retry: (state) => {
    if (!state.mediaUrl) {
      return Result.reject("No media URL to retry");
    }
    return playbackState.loading({
      ...state,
      lastError: undefined
    });
  },
  
  load: (state, signal) => playbackState.loading({
    ...state,
    mediaUrl: signal.url,
    lastError: undefined
  })
});

Volume State Flows

defineFlow(volumeState.audible, {
  setVolume: (state, signal) => {
    if (signal.level < 0 || signal.level > 1) {
      return Result.reject("Volume must be between 0 and 1");
    }
    
    return {
      ...state,
      level: signal.level,
      previousLevel: state.level > 0 ? state.level : state.previousLevel
    };
  },
  
  mute: (state) => volumeState.muted({
    ...state,
    muted: true,
    previousLevel: state.level
  }),
  
  toggleMute: (state) => volumeState.muted({
    ...state,
    muted: true,
    previousLevel: state.level
  })
});

defineFlow(volumeState.muted, {
  unmute: (state) => volumeState.audible({
    ...state,
    muted: false,
    level: state.previousLevel
  }),
  
  toggleMute: (state) => volumeState.audible({
    ...state,
    muted: false,
    level: state.previousLevel
  }),
  
  setVolume: (state, signal) => {
    // Setting volume while muted unmutes and sets new volume
    if (signal.level < 0 || signal.level > 1) {
      return Result.reject("Volume must be between 0 and 1");
    }
    
    return volumeState.audible({
      ...state,
      muted: false,
      level: signal.level
    });
  }
});

Application Integration

The media player integrates with the DOM through StateFlow's handler system, managing side effects and ensuring proper cleanup.

interface MediaPlayerApp {
  playback: Infer<typeof playbackState>;
  volume: Infer<typeof volumeState>;
  buffer: Infer<typeof bufferState>;
  ui: Infer<typeof uiState>;
  
  // Non-state properties
  element: HTMLVideoElement;
  updateInterval?: number;
}

const player: MediaPlayerApp = {
  playback: {
    position: 0,
    duration: 0,
    playbackRate: 1
  },
  volume: {
    level: 0.7,
    muted: false,
    previousLevel: 0.7
  },
  buffer: {
    bufferedRanges: [],
    isBuffering: false,
    bufferHealth: 0
  },
  ui: {
    controlsVisible: false,
    volumeSliderVisible: false,
    lastInteraction: Date.now()
  },
  element: document.querySelector('#video-player') as HTMLVideoElement
};

applyFlow(
  player,
  [playbackState, volumeState, bufferState, uiState],
  (sm) => {
    // Playback state handlers
    sm.addEnterHandler(playbackState.loading, (state) => {
      return Result.transition(async () => {
        try {
          player.element.src = state.mediaUrl!;
          player.element.load(); // HTMLMediaElement.load() returns void — nothing to await
          
          // Start monitoring buffer
          startBufferMonitoring();
          
          return Result.ok();
        } catch (error) {
          // Resolving the transition to an error result rolls the state back;
          // dispatch cannot be called while a transition is in progress
          return Result.error(error);
        }
      }, 30000); // 30 second timeout for loading
    });
    
    sm.addEnterHandler(playbackState.playing, (state) => {
      // State handlers must return a Result synchronously; async work goes
      // inside Result.transition
      return Result.transition(async () => {
        try {
          await player.element.play();
          player.element.playbackRate = state.playbackRate;
          startProgressTracking();
          return Result.ok();
        } catch (error) {
          return Result.error(error);
        }
      });
    });
    
    sm.addExitHandler(playbackState.playing, () => {
      player.element.pause();
      stopProgressTracking();
      return Result.ok();
    });
    
    sm.addUpdateHandler(playbackState.playing, (state) => {
      // Handle seek while playing
      if (Math.abs(player.element.currentTime - state.position) > 0.5) {
        player.element.currentTime = state.position;
      }
      
      // Update playback rate if changed
      if (player.element.playbackRate !== state.playbackRate) {
        player.element.playbackRate = state.playbackRate;
      }
      
      return Result.ok();
    });
    
    // Volume state handlers
    sm.addUpdateHandler(volumeState.audible, (state) => {
      player.element.volume = state.level;
      player.element.muted = false;
      updateVolumeDisplay(state.level);
      return Result.ok();
    });
    
    sm.addEnterHandler(volumeState.muted, () => {
      player.element.muted = true;
      updateVolumeDisplay(0);
      return Result.ok();
    });
    
    // Buffer monitoring.
    // Side-effect handlers run synchronously BEFORE the transition flag is set, so a
    // reentrant bare dispatch() here does NOT throw. The cleaner follow-up pattern is to
    // return Result.enqueue(signal), which queues the signal to run after this dispatch
    // cycle commits instead of recursing inline.
    sm.addEnterHandler(bufferState.starving, () => {
      // Pause playback if buffer is critically low
      if (player.playback[Symbol.toStringTag] === "playback.playing") {
        dispatch(player, mediaSignals.pause());
        showBufferingIndicator();
      }
      return Result.ok();
    });
    
    sm.addExitHandler(bufferState.starving, () => {
      hideBufferingIndicator();
      // Auto-resume if we were playing
      if (player.playback[Symbol.toStringTag] === "playback.paused") {
        dispatch(player, mediaSignals.play());
      }
      return Result.ok();
    });
  },
  {
    logHandlers: [createMediaPlayerLogger()]
  }
);

Helper Functions

Supporting functions manage the media element interaction and UI updates.

function startProgressTracking() {
  player.updateInterval = window.setInterval(async () => {
    const element = player.element;
    
    // Build buffered ranges
    const buffered: Array<{ start: number; end: number }> = [];
    for (let i = 0; i < element.buffered.length; i++) {
      buffered.push({
        start: element.buffered.start(i),
        end: element.buffered.end(i)
      });
    }
    
    // Acquire a lock so these periodic dispatches queue behind any in-flight
    // transition instead of throwing. The second argument to send() mutes logging.
    await using send = await lock(player);
    
    // Dispatch time update (muted to avoid log spam)
    await send(mediaSignals.timeUpdate({
      position: element.currentTime,
      buffered
    }), true).done();
    
    // Update buffer health
    const bufferAhead = calculateBufferAhead(
      element.currentTime,
      buffered
    );
    const health = Math.min(bufferAhead / 10, 1); // 10 seconds = full health
    
    await send(mediaSignals.bufferUpdate({
      ranges: buffered,
      health
    }), true).done();
  }, 250); // Update 4 times per second
}

function stopProgressTracking() {
  if (player.updateInterval) {
    clearInterval(player.updateInterval);
    player.updateInterval = undefined;
  }
}

function calculateBufferAhead(
  position: number,
  ranges: Array<{ start: number; end: number }>
): number {
  for (const range of ranges) {
    if (position >= range.start && position <= range.end) {
      return range.end - position;
    }
  }
  return 0;
}

function createMediaPlayerLogger(): StateFlowLogHandler {
  return (entry) => {
    // A rejected result reliably stringifies as "Rejected: <message>", so that prefix is
    // safe to match. An Error result, however, stringifies as String(error) (e.g.
    // "TypeError: ...") — it is NOT prefixed with "Error", so `finalResult.includes("Error")`
    // would both miss real errors and false-match any message that merely mentions "error".
    // The log entry carries no ResultKind, so detect failures structurally: any handler whose
    // recorded result is not an OK/Ignored marker is a problem. (If you have the Result in hand
    // at the dispatch site, check `result.kind === ResultKind.Error` directly instead.)
    const isRejected = entry.finalResult.startsWith("Rejected");
    const hasFailedHandler = entry.handlerResults.some(
      (h) => !/^(OK|Ignored)/.test(h.result)
    );

    const icon = hasFailedHandler && !isRejected ? "❌" :
                 isRejected ? "⚠️" :
                 entry.signal.includes("play") ? "▶️" :
                 entry.signal.includes("pause") ? "⏸️" :
                 entry.signal.includes("volume") ? "🔊" : "🎬";
    
    console.log(
      `${icon} ${entry.signal}${entry.finalResult}`,
      entry.stateChanges.length > 0 ? 
        entry.stateChanges.map(c => `${c.stateName}: ${String(c.newState)}`) :
        "No state changes"
    );
  };
}

Usage Examples

The media player provides a clean API for common operations while maintaining state consistency.

// Load and play media. Acquire a lock so the signals queue: lock() drains any
// in-flight transition for us, and `load` then `play` run in order even though each
// drives an async transition.
async function loadAndPlay(url: string) {
  await using send = await lock(player);

  await send(mediaSignals.load({ url, autoplay: true }))
    .expect(ResultKind.OK, ResultKind.Ignored)
    .done();
  await send(mediaSignals.play())
    .expect(ResultKind.OK, ResultKind.Ignored)
    .done();
  // lock released automatically at scope exit
}

// Seek with preview
async function handleSeekStart() {
  await using send = await lock(player);
  await send(mediaSignals.startSeeking()).expect(ResultKind.OK, ResultKind.Ignored).done();
}

async function handleSeekDrag(position: number) {
  await using send = await lock(player);
  await send(mediaSignals.seek({ position, preview: true }))
    .expect(ResultKind.OK, ResultKind.Ignored)
    .done();
}

async function handleSeekEnd(position: number) {
  await using send = await lock(player);
  await send(mediaSignals.seek({ position })).expect(ResultKind.OK, ResultKind.Ignored).done();
  await send(mediaSignals.endSeeking()).expect(ResultKind.OK, ResultKind.Ignored).done();
}

// Volume control with keyboard
async function handleVolumeKeys(event: KeyboardEvent) {
  const current = player.volume.level;
  await using send = await lock(player);
  
  switch (event.key) {
    case "ArrowUp":
      await send(mediaSignals.setVolume({ level: Math.min(current + 0.1, 1) }))
        .expect(ResultKind.OK, ResultKind.Ignored)
        .done();
      break;
      
    case "ArrowDown":
      await send(mediaSignals.setVolume({ level: Math.max(current - 0.1, 0) }))
        .expect(ResultKind.OK, ResultKind.Ignored)
        .done();
      break;
      
    case "m":
      await send(mediaSignals.toggleMute()).expect(ResultKind.OK, ResultKind.Ignored).done();
      break;
  }
}

// Error recovery
function setupErrorRecovery() {
  observe(
    player,
    [playbackState.error],
    async (state) => {
      console.error("Playback error:", state.lastError);
      
      // Auto-retry after 3 seconds for network errors
      if (state.lastError?.message.includes("network")) {
        await new Promise(resolve => setTimeout(resolve, 3000));
        await using send = await lock(player);
        await send(mediaSignals.retry()).expect(ResultKind.OK, ResultKind.Ignored).done();
      }
    }
  );
}

Testing the Media Player

StateFlow's predictable architecture makes testing straightforward. createMediaPlayer and createMockVideoElement below are illustrative test stubs that wire up a player against a fake <video> element.

describe("Media Player", () => {
  let player: MediaPlayerApp;
  let mockElement: HTMLVideoElement;
  
  beforeEach(() => {
    mockElement = createMockVideoElement();
    player = createMediaPlayer(mockElement);
  });
  
  it("should handle play/pause cycle", async () => {
    // Acquire a lock so every signal queues behind the previous transition instead of
    // throwing "States are in transitioning". The `loading` and `playing` enter handlers
    // each return Result.transition, so their dispatches resolve as InTransition; awaiting
    // .done() drains the transition before the next signal runs.
    await using send = await lock(player);

    // Load media — idle -> loading (the loading enter handler runs an async transition)
    await send(mediaSignals.load({ url: "test.mp4" })).done();
    expect(player.playback[Symbol.toStringTag]).toBe("playback.loading");

    // Buffer fills enough to become ready — loading -> ready
    await send(mediaSignals.bufferUpdate({
      ranges: [{ start: 0, end: 120 }],
      health: 1
    })).done();
    expect(player.playback[Symbol.toStringTag]).toBe("playback.ready");

    // Play — ready -> playing. The enter handler transitions, so the immediate result is
    // InTransition; .done() resolves it to OK and commits the `playing` state.
    const playResult = send(mediaSignals.play());
    expect(playResult.kind).toBe(ResultKind.InTransition);
    expect((await playResult.done()).kind).toBe(ResultKind.OK);
    expect(player.playback[Symbol.toStringTag]).toBe("playback.playing");

    // Pause — playing -> paused (synchronous, resolves OK)
    const pauseResult = await send(mediaSignals.pause()).done();
    expect(pauseResult.kind).toBe(ResultKind.OK);
    expect(player.playback[Symbol.toStringTag]).toBe("playback.paused");
  });
  
  it("should enforce volume constraints", async () => {
    await using send = await lock(player);

    // Valid volume (synchronous prop update — resolves OK)
    const validResult = await send(mediaSignals.setVolume({ level: 0.5 })).done();
    expect(validResult.kind).toBe(ResultKind.OK);
    expect(player.volume.level).toBe(0.5);
    
    // Invalid volume — rejected, so don't .expect(OK) here; assert the kind directly
    const invalidResult = await send(mediaSignals.setVolume({ level: 1.5 })).done();
    expect(invalidResult.kind).toBe(ResultKind.Rejected);
    expect(player.volume.level).toBe(0.5); // Unchanged
  });
  
  it("should handle seek validation", async () => {
    await using send = await lock(player);

    // Load media, then let the buffer fill — bufferUpdate sets duration from the ranges
    // (Math.max of range ends) and moves loading -> ready. State instances are frozen, so
    // duration is set through the flow rather than by mutating player.playback directly.
    await send(mediaSignals.load({ url: "test.mp4" })).done();
    await send(mediaSignals.bufferUpdate({
      ranges: [{ start: 0, end: 120 }],
      health: 1
    })).done();
    expect(player.playback.duration).toBe(120);
    
    // Valid seek (synchronous prop update — resolves OK)
    const validSeek = await send(mediaSignals.seek({ position: 60 })).done();
    expect(validSeek.kind).toBe(ResultKind.OK);
    
    // Invalid seek — rejected, so assert the kind directly rather than .expect(OK)
    const invalidSeek = await send(mediaSignals.seek({ position: 150 })).done();
    expect(invalidSeek.kind).toBe(ResultKind.Rejected);
  });
});

This media player example demonstrates how StateFlow's architecture provides a robust foundation for complex stateful applications. The combination of immutable states, controlled transitions through signals, and comprehensive feedback ensures that the player behaves predictably while maintaining consistency throughout its lifecycle.

Previous
Type Utilities