Files
v10/packages/store

@videojs/store

package-badge

⚠️ Alpha - SUBJECT TO CHANGE Not recommended for production use.

A reactive store for managing state owned by external systems. Built for media players, streaming libraries, and real-time systems where you don't own the state.

npm install @videojs/store

Why?

Traditional state management assumes you own the state. But when working with a <video> element, Web Sockets, streaming libraries, and real-time systems, the external system is the authority. You observe it, send requests to it, and react to its changes.

@videojs/store embraces this model:

  • Read Path: Observe external state, sync to reactive store
  • Write Path: Send requests, coordinate execution, handle failures
import { createSlice, createStore } from '@videojs/store';

const store = createStore({
  slices: [playbackSlice, audioSlice],
});

store.attach(videoElement); // <video>

// State is synced
store.state.paused; // true
store.state.volume; // 1

// Requests are coordinated async operations
await store.request.play();
await store.request.setVolume(0.5);

Core Concepts

Target

The target contains a reference to an external systems. Slices read from and write to it.

const videoElement = document.querySelector('video');
store.attach(videoElement);

Slices

A slice defines state, how to sync it from the target, and requests to modify the target.

// createSlice<Target>()() - curried form enables full type inference
const audioSlice = createSlice<HTMLMediaElement>()({
  initialState: { volume: 1, muted: false },

  getSnapshot: ({ target }) => ({
    volume: target.volume,
    muted: target.muted,
  }),

  subscribe: ({ target, update, signal }) => {
    target.addEventListener('volumechange', update, { signal });
  },

  request: {
    setVolume(volume: number, { target, meta, signal }) {
      target.volume = volume;
    },

    setMuted(muted: boolean, { target, meta, signal }) {
      target.muted = muted;
    },
  },
});

Slice Type Inference

State and request types are fully inferred from the slice config:

import type { InferSliceRequests, InferSliceState } from '@videojs/store';

const audioSlice = createSlice<HTMLMediaElement>()({
  initialState: { volume: 1, muted: false },
  // ...
});

// Infer types from the slice
type AudioState = InferSliceState<typeof audioSlice>;
type AudioRequests = InferSliceRequests<typeof audioSlice>;

For stores with multiple slices:

import type { UnionSliceRequests, UnionSliceState } from '@videojs/store';

const slices = [audioSlice, playbackSlice] as const;

type MediaState = UnionSliceState<typeof slices>;
type MediaRequests = UnionSliceRequests<typeof slices>;

Explicit Slice Types

For upfront type definitions, use Request<Input, Output>:

import type { Request } from '@videojs/store';

import { createSlice } from '@videojs/store';

interface AudioState {
  volume: number;
  muted: boolean;
}

interface AudioRequests {
  setVolume: Request<number>; // (volume: number) => Promise<void>
  setMuted: Request<boolean>; // (muted: boolean) => Promise<void>
  play: Request; // () => Promise<void>
  getDuration: Request<void, number>; // () => Promise<number>
}

const audioSlice = createSlice<HTMLMediaElement, AudioState, AudioRequests>({
  // Types enforced from interfaces
});

Requests

Requests are operations against the target. Use function shorthand for simple cases, or full config for guards and scheduling.

import { onEvent } from '@videojs/utils/events';

request: {
  // Shorthand - just the handler
  setVolume(volume, { target }) {
    target.volume = volume;
  },

  // Async shorthand
  async seek(time, { target, signal }) {
    target.currentTime = time;
    await onEvent(target, 'seeked', { signal });
  },

  // Full config when needed
  play: {
    key: 'playback',
    mode: 'shared',
    guard: [],
    cancel: [],
    async handler(_, { target, signal }) {
      target.play();
      await onEvent(target, 'play', { signal });
    },
  },
}

Consumer API is consistent, all requests return a Promise:

await store.request.setVolume(0.5);
await store.request.play();
await store.request.seek(30);

Request Metadata

Every request accepts optional metadata as the last argument.

store.request.play(null, { source: 'user', reason: 'play-button' });
store.request.seek(30, { source: 'user', reason: 'slider-scrub' });
store.request.pause(null, { source: 'system', reason: 'ad-start' });

// Infer metadata from DOM event
store.request.play(null, createRequestMetaFromEvent(clickEvent)); // MouseEvent

Handlers receive metadata:

async handler(_, { target, signal, meta }) {
  console.log(`[${meta.source}] play: ${meta.reason}`);
  // ...
}

Store

The store composes slices and manages the target connection.

const store = createStore({
  slices: [playbackSlice, audioSlice],

  onSetup: ({ store, signal }) => {
    // Called when store is created
  },

  onAttach: ({ store, target, signal }) => {
    // Called when target is attached
  },

  onError: ({ error, store }) => {
    // Global error handler
  },
});

Type Inference

import type { InferStoreRequests, InferStoreState } from '@videojs/store';

const store = createStore({ slices: [audioSlice, playbackSlice] });

type State = InferStoreState<typeof store>;
type Requests = InferStoreRequests<typeof store>;

Attaching a Target

const detach = store.attach(videoElement);

// State syncs from target
store.state.paused;
store.state.volume;

// Requests go to target
store.request.play();

// Detach when done
detach();

Destroying a Store

Clean up when the store is no longer needed:

// Detaches target, aborts pending requests, clears queue
store.destroy();

Subscribing to State

State is reactive—subscribe to be notified when any property changes:

import { subscribe, subscribeKeys } from '@videojs/store';

// Subscribe to all state changes
const unsubscribe = subscribe(store.state, () => {
  console.log('State changed:', store.state.volume);
});

// Subscribe to specific keys only
subscribeKeys(store.state, ['volume', 'muted'], () => {
  console.log('Audio changed:', store.state.volume, store.state.muted);
});

Mutations are auto-batched—multiple changes in the same tick trigger only one notification.

Slices sync state from the target via getSnapshot. The update callback triggers a sync, and the store only notifies subscribers for keys that actually changed:

subscribe: ({ target, update, signal }) => {
  // Each event triggers a full sync via getSnapshot
  // Only changed keys notify their subscribers
  target.addEventListener('timeupdate', update, { signal });
  target.addEventListener('durationchange', update, { signal });
};

Request Configuration

Keys

Requests with the same key coordinate together. Default key is the request name.

request: {
  play: {
    key: 'playback',
    handler: async (_, { target }) => { ... },
  },
  pause: {
    key: 'playback',  // same key - coordinates with play
    handler: async (_, { target }) => { ... },
  },
}

When a new request arrives with the same key:

  • Pending request with that key is aborted
  • New request takes over
store.request.play(); // starts immediately
store.request.pause(); // aborts play, starts immediately
// both start, but play is aborted mid-flight

Dynamic keys for parallel execution:

request: {
  // Each call gets unique key - no coordination
  logEvent: {
    key: () => Symbol(),
    handler: (data) => analytics.log(data),
  },

  // Key based on input
  loadTrack: {
    key: (trackId) => `track-${trackId}`,
    handler: (trackId, { target }) => target.loadTrack(trackId),
  },
}

Mode

The mode option controls how requests with the same key interact:

Mode Behavior Use case
'exclusive' (default) Supersede pending request seek, pause, volume
'shared' Join pending request, share fate play
request: {
  play: {
    key: 'playback',
    mode: 'shared',  // Multiple play() calls share the same outcome
    async handler(_, { target }) {
      await target.play();
    },
  },
  pause: {
    key: 'playback',
    mode: 'exclusive',  // default - supersedes play
    handler: (_, { target }) => target.pause(),
  },
}

With mode: 'shared', multiple calls while a request is pending all resolve/reject together:

const p1 = store.request.play(); // starts
const p2 = store.request.play(); // joins p1
const p3 = store.request.play(); // joins p1
// All three resolve/reject together when playback starts or fails

Cancels

Requests can cancel other in-flight requests by name. Cancellation happens immediately when the request is enqueued, before guards.

import { CANCEL_ALL } from '@videojs/store';

request: {
  stop: {
    cancel: ['seek', 'preload'],  // Request names to cancel
    handler: (_, { target }) => target.pause(),
  },

  load: {
    cancel: CANCEL_ALL,  // Nuclear reset - cancels ALL pending requests
    handler: (src, { target }) => {
      target.src = src;
      target.load();
    },
  },
}

Guards

Guards gate request execution. A guard returns a GuardResult:

import type { Guard, GuardResult } from '@videojs/store';

// GuardResult = boolean | Promise<unknown>
// - Truthy → proceed
// - Falsy → cancel (throws REJECTED)
// - Promise resolves truthy → proceed
// - Promise resolves falsy → cancel
// - Promise rejects → cancel
import { timeout } from '@videojs/store';

request: {
  seek: {
    guard: [hasMetadata, /* ... */],
    handler: (time, { target }) => {
      target.media.currentTime = time;
    },
  },

  play: {
    guard: timeout(isTargetReady, 5000),
    handler: (_, { target }) => target.media.play(),
  },
}

Custom guard:

import type { Guard } from '@videojs/store';

import { onEvent } from '@videojs/utils/events';

const canMediaPlay: Guard<HTMLMediaElement> = ({ target, signal }) => {
  if (target.readyState >= HAVE_ENOUGH_DATA) return true;
  return onEvent(target, 'canplay', { signal }); // wait for canplay
};

Combinators:

// All must be truthy
const canSeek = all(hasMedia, canMediaPlay, notMediaSeeking);

// First truthy wins
const ready = any(canMediaPlay, canMediaPlayThrough);

// Reject if guard doesn't resolve in time
const timedPlay = timeout(canMediaPlay, 5000);

Error Handling

All store errors include a code for programmatic handling:

Code Description
ABORTED Request aborted via signal
CANCELLED Cancelled by another request
DESTROYED Store or queue destroyed
DETACHED Target detached
NO_TARGET No target attached
REJECTED Guard returned falsy
SUPERSEDED Replaced by same-key request
TIMEOUT Guard timed out

Catch errors locally via the promise, or globally via onError:

import { isStoreError } from '@videojs/store';

// 1. Global Error Handling
const store = createStore({
  slices: [playbackSlice],
  onError: ({ error, request }) => {
    if (request) {
      console.error(`${request.name} failed`);
    }

    console.error(error);
  },
});

// 2. Local Error Handling
try {
  await store.request.play();
} catch (error) {
  if (isStoreError(error)) {
    switch (error.code) {
      case 'SUPERSEDED':
        // Another play/pause request took over - expected
        break;
      case 'REJECTED':
        // Guard failed - blocked
        break;
      case 'TIMEOUT':
        // Guard timed out waiting
        break;
      default:
        console.error(`[${error.code}]`, error.message);
    }
  }
}

Queue

The queue manages request execution with automatic supersession and lifecycle tracking. A default queue is created automatically with the store.

Queue API

const queue = store.queue;

// Task lifecycle map (pending/success/error) keyed by request name
queue.tasks;

// Abort executing tasks
queue.abort('play'); // abort specific request
queue.abort(); // abort all

// Clear settled tasks (success/error results)
queue.reset('seek'); // clear specific request
queue.reset(); // clear all settled

// Subscribe to task changes
subscribe(queue.tasks, () => {
  const playTask = queue.tasks.play;
  if (playTask?.status === 'pending') {
    console.log('Play in progress...');
  }
});

Task Lifecycle

Each request creates a task that transitions through states:

import { isErrorTask, isPendingTask, isSettledTask, isSuccessTask } from '@videojs/store';

const task = queue.tasks.play;

// Type guards for status checking
if (isPendingTask(task)) {
  console.log('In progress, started at:', task.startedAt);
}

if (isSettledTask(task)) {
  console.log('Duration:', task.settledAt - task.startedAt);
}

if (isSuccessTask(task)) {
  console.log('Result:', task.output);
}

if (isErrorTask(task)) {
  console.log('Failed:', task.error);
  console.log('Was cancelled:', task.cancelled);
}

Direct Queue Usage

You can use the queue directly without a store:

import { createQueue } from '@videojs/store';

const queue = createQueue();

await queue.enqueue({
  name: 'myTask',
  key: 'task-key',
  input: { some: 'data' },
  handler: async ({ input, signal }) => {
    // do work, check signal.aborted
    return result;
  },
});

Observing Tasks

Use subscribe to react to task changes—useful for loading states and error handling:

import { subscribe } from '@videojs/store';

subscribe(queue.tasks, () => {
  for (const [name, task] of Object.entries(queue.tasks)) {
    if (task?.status === 'error' && !task.cancelled) {
      toast.error(`${name} failed: ${task.error}`);
    }
  }
});

// Analytics
subscribe(queue.tasks, () => {
  for (const task of Object.values(queue.tasks)) {
    if (task && task.status !== 'pending') {
      analytics.track('request', {
        name: task.name,
        status: task.status,
        duration: task.settledAt - task.startedAt,
      });
    }
  }
});

Advanced

Reactive Primitives

The store uses reactive state internally. You can also use these primitives directly:

import { flush, isReactive, reactive, snapshot, subscribe, subscribeKeys } from '@videojs/store';

// Create reactive state
const state = reactive({ volume: 1, muted: false });

// Mutate directly - changes are auto-batched
state.volume = 0.5;
state.muted = true;
// Only ONE notification fires (after microtask)

// Subscribe to all changes
subscribe(state, () => console.log('Changed:', state.volume));

// Subscribe to specific keys
subscribeKeys(state, ['volume'], () => console.log('Volume:', state.volume));

// Check if value is reactive
isReactive(state); // true

// Get frozen snapshot
const snap = snapshot(state);

// Force immediate notification (mainly for tests)
flush();

Capability Checking

Slices can expose capability via state. UI components check before rendering.

const qualitySlice = createSlice<Media>({
  initialState: {
   supported: false ,
   levels: [],
   currentLevel: -1
 },

  getSnapshot: ({ target, initialState }) => {
    if (target.canSetVideoQuality) {
      return {
        supported: true,
        levels: target.levels,
        currentLevel: target.currentLevel,
      };
    }

    return initialState; // supported: false
  },

  // ...
});

// UI checks capability
function QualityMenu() {
  const { supported, levels } = useStore(store, (s) => ({
    supported: s.supported,
    levels: s.levels
  }));

  if (!supported) return null;

  return <Menu items={levels} />;
}

Optional Slices

Check if a slice exists at runtime:

function QualityMenu() {
  const quality = useSlice(store, qualitySlice);

  if (!quality) return null;

  return <Menu items={quality.state.levels} />;
}

How It's Different

Redux/Zustand React Query @videojs/store
Authority You own state Server owns state External system owns state
Mutations Sync reducers Async server requests Async requests to target
State source Internal store HTTP cache getSnapshot from target
Subscriptions To store changes To query cache To target events
Use case App state Server data Media, WebSocket, hardware

Redux/Zustand: Great for state you control. But when a <video> element is the source of truth, you end up fighting the pattern—syncing external state into the store, handling race conditions between your state and the element's actual state.

React Query: Perfect for server state with request/response. But media elements aren't request/response—they're live, event-driven systems with their own lifecycle.

@videojs/store: Built for external authority. The target is the source of truth. You observe it, request changes, and react to its events.

// Redux approach - fighting the abstraction
dispatch(play());

// Hope the video actually plays...
// Manually sync video.paused back to store...
// Handle race conditions...

// @videojs/store - working with the abstraction
await store.request.play(); // Resolves when actually playing
store.state.paused; // Always reflects video.paused

Community

If you need help with anything related to Video.js v10, or if you'd like to casually chat with other members:

License

Apache-2.0