Files
v10/packages/store
Wesley LuytenGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
e2d55de26b chore: release main (#1072)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 16:54:03 -07:00
..
2026-03-20 16:54:03 -07:00
2026-03-20 16:54:03 -07:00

@videojs/store

package-badge

⚠️ Beta Close to stable. Experimental adoption in real projects.

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 to the target, 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: ({ target }) => ({
    volume: 1,
    muted: false,

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

    // Action - directly updates target
    toggleMuted() {
      const media = target();
      media.muted = !media.muted;
      return media.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; setVolume: ...; toggleMuted: ... }

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. You can call target() to access the attached target.

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

  // Action
  setVolume(volume: number) {
    const media = target();
    media.volume = volume;
    return media.volume;
  },

  // Fire-and-forget
  logVolume() {
    console.log('Current volume:', target().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
    },
  }
);

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 signals, 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.

Cancellation Signals

Use signals to manage cancellation for async operations. The store provides an AbortControllerRegistry instance that tracks the attach lifecycle and supports keyed cancellation for superseding work.

state: ({ target, signals }) => ({
  // Supersede pattern: new seek cancels previous seek
  async seek(time: number) {
    const signal = signals.supersede(signalKeys.seek);
    // ...
  },

  // Cancel all pending operations (e.g., when loading new source)
  loadSource(src: string) {
    signals.clear();
    // ...
  },
}),

API:

Method Description
signals.base Attach-scoped signal. Aborts on detach or reattach.
signals.supersede(key) Returns signal that aborts when same key is superseded or base aborts.
signals.clear() Aborts all keyed signals, leaving base intact.

Define shared keys for cross-slice coordination:

export const signalKeys = {
  seek: Symbol.for('@videojs/seek'),
} as const;

Error Handling

Handle errors locally via try/catch, 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 'NO_TARGET':
        // No media element attached
        break;
      default:
        console.error(`[${error.code}]`, error.message);
    }
  }
}

All store errors include a code for programmatic handling:

Code Description
DESTROYED Store destroyed
NO_TARGET No target attached

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);
});

// Optional abort signal for cleanup
const controller = new AbortController();
state.subscribe(() => {}, { signal: controller.signal });
controller.abort();

// 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 Actions 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
store.play();                 // Actions call into the target
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