;
- act(() => {
- mutePromise = muteResult.current.setValue(true);
- });
-
- // Mute shows optimistic immediately
- expect(muteResult.current.value).toBe(true);
-
- // Volume task was superseded
- await act(async () => {
- try {
- await volumePromise;
- } catch {
- // Expected - task was superseded
- }
- });
-
- // Wait for subscription callbacks to fire
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 10));
- });
-
- // Tasks are keyed by name, so superseded task shows error status
- expect(volumeResult.current.status).toBe('error');
-
- // Complete the mute operation
- await act(async () => {
- await mutePromise;
- });
-
- expect(muteResult.current.status).toBe('success');
- });
- });
-});
diff --git a/packages/store/src/react/hooks/tests/use-selector.test.tsx b/packages/store/src/react/hooks/tests/use-selector.test.tsx
deleted file mode 100644
index 219bdc1a..00000000
--- a/packages/store/src/react/hooks/tests/use-selector.test.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { act, renderHook } from '@testing-library/react';
-
-import { describe, expect, it, vi } from 'vitest';
-
-import { useSelector } from '../use-selector';
-import { createTestStore } from './test-utils';
-
-describe('useSelector', () => {
- it('returns selected state', () => {
- const { store } = createTestStore();
-
- const { result } = renderHook(() => useSelector(store, s => s.volume));
-
- expect(result.current).toBe(1);
- });
-
- it('re-renders when selected state changes', async () => {
- const { store, target } = createTestStore();
-
- const { result } = renderHook(() => useSelector(store, s => s.volume));
-
- expect(result.current).toBe(1);
-
- await act(async () => {
- target.volume = 0.5;
- target.dispatchEvent(new Event('volumechange'));
- });
-
- expect(result.current).toBe(0.5);
- });
-
- it('does not re-render when unrelated state changes', async () => {
- const { store, target } = createTestStore();
- const renderCount = vi.fn();
-
- renderHook(() => {
- renderCount();
- return useSelector(store, s => s.volume);
- });
-
- expect(renderCount).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- target.muted = true;
- target.dispatchEvent(new Event('volumechange'));
- });
-
- // Should not re-render because volume didn't change
- expect(renderCount).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/packages/store/src/react/hooks/use-mutation.ts b/packages/store/src/react/hooks/use-mutation.ts
deleted file mode 100644
index e6cd0497..00000000
--- a/packages/store/src/react/hooks/use-mutation.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { EnsureFunction } from '@videojs/utils/types';
-import type { AnyStore, InferStoreRequests } from '../../core/store';
-import type { Task } from '../../core/task';
-import type { MutationResult } from '../../shared/types';
-
-import { useCallback, useRef, useSyncExternalStore } from 'react';
-
-/**
- * Track a store request as a mutation with status, data, and error.
- *
- * Subscribes to the task queue and re-renders when the mutation's status changes.
- *
- * Returns a discriminated union — use `status` to narrow the type and access
- * `data` (on success) or `error` (on failure).
- *
- * @param store - The store instance containing the request
- * @param name - The request name to track (type-safe with autocomplete)
- * @returns A discriminated union with the mutation's current state
- *
- * @example
- * ```tsx
- * function SourceSelector() {
- * const source = useMutation(store, 'setSource');
- *
- * return (
- * <>
- *
- * {source.status === 'error' && (
- * Failed to load: {String(source.error)}
- * )}
- * >
- * );
- * }
- * ```
- */
-export function useMutation<
- Store extends AnyStore,
- Name extends keyof InferStoreRequests,
- Mutate extends InferStoreRequests[Name] = InferStoreRequests[Name],
->(store: Store, name: Name): MutationResult>>> {
- type Data = Awaited>>;
-
- const taskRef = useRef(store.queue.tasks[name]);
-
- const subscribe = useCallback(
- (onStoreChange: () => void) =>
- store.queue.subscribe((tasks) => {
- const newTask = tasks[name];
- if (newTask !== taskRef.current) {
- taskRef.current = newTask;
- onStoreChange();
- }
- }),
- [store, name],
- );
-
- const getSnapshot = useCallback(() => taskRef.current, []);
-
- const task = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
-
- const resetRef = useRef(() => store.queue.reset(name));
-
- const base = {
- mutate: store.request[name] as Mutate,
- reset: resetRef.current,
- };
-
- if (task?.status === 'success') {
- return {
- status: 'success',
- ...base,
- data: task.output as Data,
- };
- }
-
- if (task?.status === 'error') {
- return {
- status: 'error',
- ...base,
- error: task.error,
- };
- }
-
- return {
- status: task?.status ?? 'idle',
- ...base,
- } as MutationResult;
-}
-
-export namespace useMutation {
- export type Result = MutationResult;
-}
diff --git a/packages/store/src/react/hooks/use-optimistic.ts b/packages/store/src/react/hooks/use-optimistic.ts
deleted file mode 100644
index 61df3ce5..00000000
--- a/packages/store/src/react/hooks/use-optimistic.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import type { EnsureFunction } from '@videojs/utils/types';
-import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store';
-import type { Task } from '../../core/task';
-import type { OptimisticResult } from '../../shared/types';
-
-import { useCallback, useReducer, useRef, useSyncExternalStore } from 'react';
-
-/**
- * Track a store request with optimistic updates.
- *
- * Shows the optimistic value immediately while the request is pending,
- * then updates to the actual value on success or reverts on error.
- *
- * Returns a discriminated union — use `status` to narrow the type.
- *
- * @param store - The store instance containing the request
- * @param name - The request name to track (type-safe with autocomplete)
- * @param selector - Function to select the value from store state
- * @returns A discriminated union with the current optimistic state
- *
- * @example
- * ```tsx
- * function VolumeSlider() {
- * const result = useOptimistic(store, 'setVolume', s => s.volume);
- *
- * return (
- * result.setValue(Number(e.target.value))}
- * style={{ opacity: result.status === 'pending' ? 0.5 : 1 }}
- * />
- * );
- * }
- * ```
- */
-export function useOptimistic<
- Store extends AnyStore,
- Name extends keyof InferStoreRequests,
- Value,
- Request extends InferStoreRequests[Name] = InferStoreRequests[Name],
->(
- store: Store,
- name: Name,
- selector: (state: InferStoreState) => Value,
-): OptimisticResult ReturnType>> {
- // Force update mechanism for optimistic value changes
- const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
-
- // Track optimistic value (null = use actual state)
- const optimisticRef = useRef(null);
- const taskRef = useRef(store.queue.tasks[name]);
-
- // Subscribe to store state for actual value
- const subscribeToState = useCallback(
- (onStoreChange: () => void) => store.subscribe(selector, onStoreChange),
- [store, selector],
- );
-
- const getStateSnapshot = useCallback(() => selector(store.state), [store, selector]);
-
- const actualValue = useSyncExternalStore(subscribeToState, getStateSnapshot, getStateSnapshot);
-
- // Subscribe to task queue for status
- const subscribeToQueue = useCallback(
- (onStoreChange: () => void) =>
- store.queue.subscribe((tasks) => {
- const newTask = tasks[name];
- if (newTask !== taskRef.current) {
- taskRef.current = newTask;
-
- // Clear optimistic value when task settles
- if (optimisticRef.current !== null && newTask?.status !== 'pending') {
- optimisticRef.current = null;
- }
-
- onStoreChange();
- }
- }),
- [store, name],
- );
-
- const getQueueSnapshot = useCallback(() => taskRef.current, []);
-
- const task = useSyncExternalStore(subscribeToQueue, getQueueSnapshot, getQueueSnapshot);
-
- // setValue: set optimistic value and call request
- const setValueRef = useRef((newValue: Value): ReturnType> => {
- optimisticRef.current = newValue;
- forceUpdate();
-
- const request = store.request[name] as (value: Value) => ReturnType>;
- return request(newValue);
- });
-
- // reset: clear optimistic value and reset task
- const resetRef = useRef(() => {
- optimisticRef.current = null;
- forceUpdate();
-
- taskRef.current = store.queue.tasks[name];
- if (taskRef.current) store.queue.reset(name);
- });
-
- // Build result with discriminated union
- const value = optimisticRef.current !== null ? optimisticRef.current : actualValue;
- const base = {
- value,
- setValue: setValueRef.current,
- reset: resetRef.current,
- };
-
- if (task?.status === 'error') {
- return {
- status: 'error',
- ...base,
- error: task.error,
- };
- }
-
- return {
- status: task?.status ?? 'idle',
- ...base,
- };
-}
-
-export namespace useOptimistic {
- export type Result = OptimisticResult;
-}
diff --git a/packages/store/src/react/hooks/use-selector.ts b/packages/store/src/react/hooks/use-selector.ts
deleted file mode 100644
index 2f95230b..00000000
--- a/packages/store/src/react/hooks/use-selector.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { AnyStore, InferStoreState } from '../../core/store';
-
-import { useCallback, useSyncExternalStore } from 'react';
-
-/**
- * Subscribe to a slice of store state.
- *
- * Only re-renders when the selected value changes (shallow comparison).
- *
- * The selector function should return a stable reference for objects
- * to avoid unnecessary re-renders.
- *
- * @param store - The store instance to subscribe to
- * @param selector - Function that extracts the desired value from state
- * @returns The selected value, updated when it changes
- *
- * @example
- * ```tsx
- * function VolumeDisplay() {
- * const volume = useSelector(store, (s) => s.volume);
- * return {Math.round(volume * 100)}%;
- * }
- * ```
- */
-export function useSelector(store: S, selector: (state: InferStoreState) => T): T {
- const subscribe = useCallback(
- (onStoreChange: () => void) => store.subscribe(selector, onStoreChange),
- [store, selector],
- );
-
- const getSnapshot = useCallback(() => selector(store.state), [store, selector]);
-
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
-}
diff --git a/packages/store/src/react/hooks/use-snapshot.ts b/packages/store/src/react/hooks/use-snapshot.ts
new file mode 100644
index 00000000..1362b292
--- /dev/null
+++ b/packages/store/src/react/hooks/use-snapshot.ts
@@ -0,0 +1,34 @@
+import type { Reactive } from '../../core/state';
+
+import { useState, useSyncExternalStore } from 'react';
+import { track } from '../../core/state';
+
+/**
+ * Subscribe to reactive state and re-render when accessed properties change.
+ *
+ * Automatically tracks which properties are accessed during render and only
+ * re-renders when those specific properties change.
+ *
+ * @param state - Reactive state created by `reactive()`
+ * @returns The state, which triggers re-renders when accessed properties change
+ *
+ * @example
+ * ```tsx
+ * function VolumeDisplay() {
+ * const state = useSnapshot(store.state);
+ * return {Math.round(state.volume * 100)}%;
+ * }
+ * ```
+ */
+export function useSnapshot(state: Reactive): T {
+ const [{ tracked, subscribe, getSnapshot, next }] = useState(() => track(state));
+
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ next();
+
+ return tracked;
+}
+
+export namespace useSnapshot {
+ export type Result = T;
+}
diff --git a/packages/store/src/react/hooks/use-tasks.ts b/packages/store/src/react/hooks/use-tasks.ts
index 9cab4ddc..edef7eef 100644
--- a/packages/store/src/react/hooks/use-tasks.ts
+++ b/packages/store/src/react/hooks/use-tasks.ts
@@ -3,15 +3,14 @@ import type { AnyStore, InferStoreTasks } from '../../core/store';
import { useCallback, useRef, useSyncExternalStore } from 'react';
+import { subscribe } from '../../core/state';
+
/**
* Subscribe to task queue state.
*
* Returns a record of all tasks keyed by request name.
* Re-renders when any task is added, updated, or removed.
*
- * For tracking a single mutation, prefer `useMutation` which provides a more ergonomic API with
- * status helpers.
- *
* @param store - The store instance to subscribe to
* @returns Record of tasks keyed by request name
*
@@ -33,18 +32,21 @@ import { useCallback, useRef, useSyncExternalStore } from 'react';
* ```
*/
export function useTasks(store: S): TasksRecord> {
- const tasksRef = useRef(store.queue.tasks);
+ const versionRef = useRef(0);
- const subscribe = useCallback(
+ const subscribeToQueue = useCallback(
(onStoreChange: () => void) =>
- store.queue.subscribe((tasks) => {
- tasksRef.current = tasks;
+ subscribe(store.queue.tasks, () => {
+ versionRef.current++;
onStoreChange();
}),
[store],
);
- const getSnapshot = useCallback(() => tasksRef.current as TasksRecord>, []);
+ const getSnapshot = useCallback(() => versionRef.current, []);
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ useSyncExternalStore(subscribeToQueue, getSnapshot, getSnapshot);
+
+ // Return the tasks proxy directly
+ return store.queue.tasks as TasksRecord>;
}
diff --git a/packages/store/src/react/index.ts b/packages/store/src/react/index.ts
index 23076328..7f908660 100644
--- a/packages/store/src/react/index.ts
+++ b/packages/store/src/react/index.ts
@@ -3,4 +3,4 @@ export { useStoreContext } from './context';
export { createStore } from './create-store';
export type { CreateStoreConfig, CreateStoreResult, ProviderProps } from './create-store';
-export { useMutation, useOptimistic, useRequest, useSelector, useTasks } from './hooks';
+export { useRequest, useSnapshot, useTasks } from './hooks';
diff --git a/packages/store/src/react/tests/create-store.test.tsx b/packages/store/src/react/tests/create-store.test.tsx
index 736e33e2..e717e6ec 100644
--- a/packages/store/src/react/tests/create-store.test.tsx
+++ b/packages/store/src/react/tests/create-store.test.tsx
@@ -143,38 +143,38 @@ describe('createStore', () => {
});
});
- describe('useSelector', () => {
- it('selects state from context store', () => {
- const { Provider, useSelector, create } = createStore({ slices: [audioSlice] });
+ describe('useSnapshot', () => {
+ it('returns state from context store', () => {
+ const { Provider, useSnapshot, create } = createStore({ slices: [audioSlice] });
const store = create();
const target = new MockMedia();
store.attach(target);
- const { result } = renderHook(() => useSelector(s => s.volume), {
+ const { result } = renderHook(() => useSnapshot(), {
wrapper: ({ children }: { children: ReactNode }) => {children},
});
- expect(result.current).toBe(1);
+ expect(result.current.volume).toBe(1);
});
it('updates when state changes', async () => {
- const { Provider, useSelector, create } = createStore({ slices: [audioSlice] });
+ const { Provider, useSnapshot, create } = createStore({ slices: [audioSlice] });
const store = create();
const target = new MockMedia();
store.attach(target);
- const { result } = renderHook(() => useSelector(s => s.volume), {
+ const { result } = renderHook(() => useSnapshot(), {
wrapper: ({ children }: { children: ReactNode }) => {children},
});
- expect(result.current).toBe(1);
+ expect(result.current.volume).toBe(1);
await act(async () => {
target.volume = 0.5;
target.dispatchEvent(new Event('volumechange'));
});
- expect(result.current).toBe(0.5);
+ expect(result.current.volume).toBe(0.5);
});
});
diff --git a/packages/store/src/shared/types.ts b/packages/store/src/shared/types.ts
index ebda14aa..5a99df64 100644
--- a/packages/store/src/shared/types.ts
+++ b/packages/store/src/shared/types.ts
@@ -1,7 +1,3 @@
-// ----------------------------------------
-// Async Status
-// ----------------------------------------
-
/**
* Lifecycle status for async operations.
*
@@ -11,134 +7,3 @@
* - `'error'` — Operation failed with an error
*/
export type AsyncStatus = 'idle' | 'pending' | 'success' | 'error';
-
-// ----------------------------------------
-// Mutation Types
-// ----------------------------------------
-
-/**
- * Common properties shared by all mutation states.
- */
-interface MutationBase {
- status: AsyncStatus;
- mutate: Mutate;
- reset: () => void;
-}
-
-/**
- * Mutation hasn't been triggered yet.
- * This is the initial state before calling `mutate()`.
- */
-export interface MutationIdle extends MutationBase {
- status: 'idle';
-}
-
-/**
- * Mutation is in flight, waiting for the request to complete.
- * The UI should typically show a loading indicator.
- */
-export interface MutationPending extends MutationBase {
- status: 'pending';
-}
-
-/**
- * Mutation completed successfully.
- * The `data` property contains the request's return value.
- */
-export interface MutationSuccess extends MutationBase {
- status: 'success';
- data: Data;
-}
-
-/**
- * Mutation failed with an error.
- * The `error` property contains the thrown exception.
- */
-export interface MutationError extends MutationBase {
- status: 'error';
- error: unknown;
-}
-
-/**
- * Discriminated union representing all possible mutation states.
- *
- * Use `status` to narrow the type and access state-specific properties:
- *
- * ```ts
- * if (mutation.status === 'success') {
- * console.log(mutation.data); // Data is available
- * }
- * if (mutation.status === 'error') {
- * console.log(mutation.error); // Error is available
- * }
- * ```
- */
-export type MutationResult
- = | MutationIdle
- | MutationPending
- | MutationSuccess
- | MutationError