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.

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!;
          await player.element.load();
          
          // Start monitoring buffer
          startBufferMonitoring();
          
          return Result.ok();
        } catch (error) {
          return dispatch(player, mediaSignals.handleError({
            error: error as Error,
            recoverable: true
          }));
        }
      }, 30000); // 30 second timeout for loading
    });
    
    sm.addEnterHandler(playbackState.playing, async (state) => {
      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
    sm.addEnterHandler(bufferState.starving, () => {
      // Pause playback if buffer is critically low
      if (player.playback.playing) {
        dispatch(player, mediaSignals.pause());
        showBufferingIndicator();
      }
      return Result.ok();
    });
    
    sm.addExitHandler(bufferState.starving, () => {
      hideBufferingIndicator();
      // Auto-resume if we were playing
      if (player.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(() => {
    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)
      });
    }
    
    // Dispatch time update
    dispatch(player, mediaSignals.timeUpdate({
      position: element.currentTime,
      buffered
    }), true); // Mute to avoid log spam
    
    // Update buffer health
    const bufferAhead = calculateBufferAhead(
      element.currentTime,
      buffered
    );
    const health = Math.min(bufferAhead / 10, 1); // 10 seconds = full health
    
    dispatch(player, mediaSignals.bufferUpdate({
      ranges: buffered,
      health
    }), true);
  }, 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) => {
    // Custom formatting for media player events
    const icon = entry.finalResult.includes("Error") ? "❌" :
                 entry.finalResult.includes("Rejected") ? "⚠️" :
                 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
async function loadAndPlay(url: string) {
  await sync(player);
  
  const loadResult = dispatch(player, mediaSignals.load({ 
    url, 
    autoplay: true 
  }));
  
  if (loadResult.kind === ResultKind.InTransition) {
    const final = await loadResult.done();
    if (final.kind === ResultKind.OK) {
      dispatch(player, mediaSignals.play());
    }
  }
}

// Seek with preview
function handleSeekStart() {
  dispatch(player, mediaSignals.startSeeking());
}

function handleSeekDrag(position: number) {
  dispatch(player, mediaSignals.seek({ 
    position, 
    preview: true 
  }));
}

function handleSeekEnd(position: number) {
  dispatch(player, mediaSignals.seek({ position }));
  dispatch(player, mediaSignals.endSeeking());
}

// Volume control with keyboard
function handleVolumeKeys(event: KeyboardEvent) {
  const current = player.volume.level;
  
  switch (event.key) {
    case "ArrowUp":
      dispatch(player, mediaSignals.setVolume({ 
        level: Math.min(current + 0.1, 1) 
      }));
      break;
      
    case "ArrowDown":
      dispatch(player, mediaSignals.setVolume({ 
        level: Math.max(current - 0.1, 0) 
      }));
      break;
      
    case "m":
      dispatch(player, mediaSignals.toggleMute());
      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));
        dispatch(player, mediaSignals.retry());
      }
    }
  );
}

Testing the Media Player

StateFlow's predictable architecture makes testing straightforward.

describe("Media Player", () => {
  let player: MediaPlayerApp;
  let mockElement: HTMLVideoElement;
  
  beforeEach(() => {
    mockElement = createMockVideoElement();
    player = createMediaPlayer(mockElement);
  });
  
  it("should handle play/pause cycle", async () => {
    // Load media
    const loadResult = dispatch(player, mediaSignals.load({ 
      url: "test.mp4" 
    }));
    await loadResult.done();
    
    // Verify ready state
    expect(player.playback[Symbol.toStringTag]).toBe("ready");
    
    // Play
    const playResult = dispatch(player, mediaSignals.play());
    expect(playResult.kind).toBe(ResultKind.OK);
    expect(player.playback[Symbol.toStringTag]).toBe("playing");
    
    // Pause
    const pauseResult = dispatch(player, mediaSignals.pause());
    expect(pauseResult.kind).toBe(ResultKind.OK);
    expect(player.playback[Symbol.toStringTag]).toBe("paused");
  });
  
  it("should enforce volume constraints", () => {
    // Valid volume
    const validResult = dispatch(player, mediaSignals.setVolume({ 
      level: 0.5 
    }));
    expect(validResult.kind).toBe(ResultKind.OK);
    expect(player.volume.level).toBe(0.5);
    
    // Invalid volume
    const invalidResult = dispatch(player, mediaSignals.setVolume({ 
      level: 1.5 
    }));
    expect(invalidResult.kind).toBe(ResultKind.Rejected);
    expect(player.volume.level).toBe(0.5); // Unchanged
  });
  
  it("should handle seek validation", async () => {
    // Load media with known duration
    await dispatch(player, mediaSignals.load({ 
      url: "test.mp4" 
    })).done();
    
    // Mock duration
    player.playback.duration = 120;
    
    // Valid seek
    const validSeek = dispatch(player, mediaSignals.seek({ 
      position: 60 
    }));
    expect(validSeek.kind).toBe(ResultKind.OK);
    
    // Invalid seek
    const invalidSeek = dispatch(player, mediaSignals.seek({ 
      position: 150 
    }));
    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