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 { createStore, defineSlice } from '@videojs/store';

const volumeSlice = defineSlice<HTMLMediaElement>()({
  state: () => ({ volume: 1 }),
  attach: ({ target, set, signal }) => {
    const sync = () => set({ volume: target.volume });
    target.addEventListener('volumechange', sync, { signal });
  },
});

const store = createStore<HTMLMediaElement>()(volumeSlice);
store.attach(videoElement);

// State is flat on the store
const { volume } = store;

Core Concepts

Target

The target is a reference to the external system. 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 actions to modify the target.

import { defineSlice } from '@videojs/store';
import { listen } from '@videojs/utils/dom';

const volumeSlice = defineSlice<HTMLMediaElement>()({
  state: ({ task, target }) => ({
    volume: 1,
    muted: false,

    // Sync - use target() directly
    setVolume() {
      target().volume = Math.max(0, Math.min(1, value));
    },

    // Task - tracked, coordinated
    toggleMute() {
      return task({
        key: 'mute',
        handler({ target }) {
          target.muted = !target.muted;
          return target.muted;
        },
      });
    },
  }),

  attach({ target, signal, set }) {
    const sync = () => set({ volume: target.volume, muted: target.muted });

    sync();

    listen(target, 'volumechange', sync, { signal });
  },
});

Slice Type Inference

State types are fully inferred from the slice config:

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

const volumeSlice = defineSlice<HTMLMediaElement>()({
  state: () => ({ volume: 1, muted: false, /* actions */ }),
  // ...
});

// Infer types from the slice
type VolumeState = InferSliceState<typeof volumeSlice>;
// { volume: number; muted: boolean; changeVolume: ...; toggleMute: ... }

Combining Slices

Use combine to merge multiple slices into one:

import { combine, createStore, defineSlice } from '@videojs/store';

const volumeSlice = defineSlice<HTMLMediaElement>()({ /* ... */ });
const playbackSlice = defineSlice<HTMLMediaElement>()({ /* ... */ });

// Combine into a single slice
const mediaSlice = combine(volumeSlice, playbackSlice);
const store = createStore<HTMLMediaElement>()(mediaSlice);

Behavior:

  • State factories are called in order, results merged (last wins on conflict)
  • All attach handlers run; errors are caught and reported via reportError
  • Use UnionSliceState<Slices> for combined state type inference
import type { UnionSliceState } from '@videojs/store';

const slices = [volumeSlice, playbackSlice] as const;
type MediaState = UnionSliceState<typeof slices>;

Actions

Actions modify the target. Use task() for operations—handlers receive target directly.

state: ({ task }) => ({
  volume: 1,

  // Action with tracking (has key)
  changeVolume(volume: number) {
    return task({
      key: 'volume',
      handler({ target }) {
        target.volume = volume;
        return target.volume;
      },
    });
  },

  // Fire-and-forget (no key)
  logVolume() {
    return task(({ target }) => {
      console.log('Current volume:', target.volume);
    });
  },
}),

The task() helper provides:

  • Tracked execution — Tasks with a key appear in store.pending
  • Cancellation — Handlers receive signal: AbortSignal
  • Coordination — Tasks with the same key supersede each other
  • State access — Handlers can read current state via get()
handler({ target, signal, get, meta }) {
  // target - the attached target
  // signal - AbortSignal for cancellation
  // get() - current state snapshot
  // meta - request metadata (from store.meta())
}

Action Metadata

Pass metadata to actions for observability and debugging:

// From DOM event
store.meta(clickEvent).play();
store.meta(keyEvent).seek(10);

// Explicit metadata
store.meta({ source: 'keyboard', reason: 'shortcut' }).play();

// Chain multiple actions with same context
const m = store.meta(event);
m.play();
m.seek(30);

Handlers receive metadata via the context:

changeVolume(volume: number) {
  return task({
    key: 'volume',
    handler({ target, meta }) {
      console.log(`Volume change from: ${meta?.source}`);
      target.volume = volume;
    },
  });
},

Store

The store connects a slice to a target.

// Simple
const store = createStore<HTMLMediaElement>()(volumeSlice);

// With combined slices and options
const store = createStore<HTMLMediaElement>()(
  combine(volumeSlice, playbackSlice),
  {
    onSetup: ({ store, signal }) => {
      // Called when store is created
    },

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

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

    onTaskStart: ({ key, meta }) => {
      // Called when a tracked task starts
    },

    onTaskEnd: ({ key, meta, error }) => {
      // Called when a tracked task completes
    },
  }
);

Type Inference

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

const store = createStore<HTMLMediaElement>()(volumeSlice);

type State = InferStoreState<typeof store>;
type Target = InferStoreTarget<typeof store>;

Attaching a Target

const detach = store.attach(videoElement);

// State syncs from target (flat access)
const { paused, volume } = store;

// Actions go to target (flat access)
store.play();
store.setVolume(0.5);

// Detach when done
detach();

Destroying a Store

Clean up when the store is no longer needed:

// Detaches target, aborts pending tasks, cleans up
store.destroy();

Subscribing to State

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

const unsubscribe = store.subscribe(() => {
  const { volume } = store;
  console.log('State changed:', volume);
});

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

Pending Tasks

Track in-flight async operations:

// Check if a task is running
if (store.pending.playback) {
  console.log('Playback task in progress...');
}

// Pending task info
const task = store.pending.volume;
if (task) {
  console.log(task.key);       // 'volume'
  console.log(task.startedAt); // timestamp
  console.log(task.meta);      // RequestMeta | null
}

Task Configuration

Keys

Tasks with the same key coordinate together. When a new task arrives with the same key, the pending task is aborted and the new one takes over.

state: ({ task }) => ({
  play() {
    return task({
      key: 'playback',
      handler: ({ target }) => target.play(),
    });
  },

  pause() {
    return task({
      key: 'playback', // same key - coordinates with play
      handler: ({ target }) => target.pause(),
    });
  },
}),
store.play();  // starts immediately
store.pause(); // aborts play, starts immediately

Dynamic keys for parallel execution:

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

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

Mode

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

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

pause() {
  return task({
    key: 'playback',
    mode: 'exclusive', // default - supersedes play
    handler: ({ target }) => target.pause(),
  });
},

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

const p1 = store.play(); // starts
const p2 = store.play(); // joins p1
const p3 = store.play(); // joins p1
// All three resolve/reject together

Cancels

Tasks can cancel other in-flight tasks by key:

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

stop() {
  return task({
    cancels: ['seek', 'preload'], // Keys to cancel
    handler: ({ target }) => target.pause(),
  });
},

load(src: string) {
  return task({
    cancels: CANCEL_ALL, // Cancel ALL pending tasks
    handler: ({ target }) => {
      target.src = src;
      target.load();
    },
  });
},

Error Handling

All store errors include a code for programmatic handling:

Code Description
ABORTED Task aborted via signal
DESTROYED Store destroyed
NO_TARGET No target attached
SUPERSEDED Replaced by same-key task

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

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

// Global error handling
const store = createStore<HTMLMediaElement>()(volumeSlice, {
  onError: ({ error, store }) => {
    console.error('Store error:', error);
  },
});

// Local error handling
try {
  await store.play();
} catch (error) {
  if (isStoreError(error)) {
    switch (error.code) {
      case 'SUPERSEDED':
        // Another task took over - expected
        break;
      case 'NO_TARGET':
        // No media element attached
        break;
      default:
        console.error(`[${error.code}]`, error.message);
    }
  }
}

Advanced

State Primitives

The store uses explicit state containers internally. You can use these primitives directly:

import { createState, flush, isState } from '@videojs/store';

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

// Read via .current
const { volume } = state.current; // 1

// Mutate via patch() - changes are auto-batched
state.patch({ volume: 0.5 });
state.patch({ volume: 0.5, muted: true });
// Only ONE notification fires (after microtask)

// Subscribe to changes
state.subscribe(() => {
  const { volume } = state.current;
  console.log('Changed:', volume);
});

// Check if value is state
isState(state); // true

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

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 tasks to target
State source Internal store HTTP cache Synced 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.play();           // Resolves when task completes
const { paused } = store;     // 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