feat(store): useMutation hook for react (#290)

This commit is contained in:
rahim
2026-01-07 00:10:41 +11:00
committed by GitHub
parent a71a280bdc
commit ecd4940bc7
23 changed files with 827 additions and 398 deletions
+10 -12
View File
@@ -9,7 +9,11 @@ import { useEffect, useState } from 'react';
import { Store } from '../core/store';
import { StoreContextProvider, useParentStore, useStoreContext } from './context';
import { useRequest as useRequestBase, useSelector as useSelectorBase, useTasks as useTasksBase } from './hooks';
import {
useRequest as useRequestBase,
useSelector as useSelectorBase,
useTasks as useTasksBase,
} from './hooks';
// ----------------------------------------
// Types
@@ -56,11 +60,11 @@ export interface CreateStoreResult<Slices extends AnySlice[]> {
useSelector: <T>(selector: (state: UnionSliceState<Slices>) => T) => T;
/**
* Returns the request map or a selected request.
* Returns the request map or a specific request by name.
*/
useRequest: {
(): UnionSliceRequests<Slices>;
<T>(selector: (requests: UnionSliceRequests<Slices>) => T): T;
<Name extends keyof UnionSliceRequests<Slices>>(name: Name): UnionSliceRequests<Slices>[Name];
};
/**
@@ -157,16 +161,10 @@ export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig
}
function useRequest(): Requests;
function useRequest<T>(selector: (requests: Requests) => T): T;
function useRequest<T>(selector?: (requests: Requests) => T): Requests | T {
function useRequest<Name extends keyof Requests>(name: Name): Requests[Name];
function useRequest<Name extends keyof Requests>(name?: Name): Requests | Requests[Name] {
const store = useStore();
const requests = useRequestBase(store);
if (isUndefined(selector)) {
return requests;
}
return selector(requests);
return useRequestBase(store, name as Name);
}
function useTasks(): TasksRecord<Tasks> {
-60
View File
@@ -1,60 +0,0 @@
import type { TasksRecord } from '../core/queue';
import type { AnyStore, InferStoreRequests, InferStoreState, InferStoreTasks } from '../core/store';
import { isUndefined } from '@videojs/utils/predicate';
import { useCallback, useRef, useSyncExternalStore } from 'react';
/**
* Subscribe to selected state. Re-renders only when selected value changes.
*/
export function useSelector<S extends AnyStore, T>(store: S, selector: (state: InferStoreState<S>) => 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);
}
/**
* Get request map or select a specific request.
*/
export function useRequest<S extends AnyStore>(store: S): InferStoreRequests<S>;
export function useRequest<S extends AnyStore, T>(store: S, selector: (requests: InferStoreRequests<S>) => T): T;
// eslint-disable-next-line react/no-unnecessary-use-prefix
export function useRequest<S extends AnyStore, T>(
store: S,
selector?: (requests: InferStoreRequests<S>) => T,
): InferStoreRequests<S> | T {
const request = store.request as InferStoreRequests<S>;
if (isUndefined(selector)) {
return request;
}
return selector(request);
}
/**
* Subscribe to task state changes.
*/
export function useTasks<S extends AnyStore>(store: S): TasksRecord<InferStoreTasks<S>> {
const tasksRef = useRef(store.queue.tasks);
const subscribe = useCallback(
(onStoreChange: () => void) =>
store.queue.subscribe((tasks) => {
tasksRef.current = tasks;
onStoreChange();
}),
[store],
);
const getSnapshot = useCallback(() => tasksRef.current as TasksRecord<InferStoreTasks<S>>, []);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
+13
View File
@@ -0,0 +1,13 @@
export type {
AsyncStatus,
MutationError,
MutationIdle,
MutationPending,
MutationResult,
MutationSuccess,
} from '../../shared/types';
export { useMutation } from './use-mutation';
export { useRequest } from './use-request';
export { useSelector } from './use-selector';
export { useTasks } from './use-tasks';
@@ -0,0 +1,92 @@
import { noop } from '@videojs/utils/function';
import { createSlice } from '../../../core/slice';
import { createStore as createCoreStore } from '../../../core/store';
// Shared mock target for synchronous tests
export class MockMedia extends EventTarget {
volume = 1;
muted = false;
}
// Shared slice for synchronous tests
export const audioSlice = createSlice<MockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: ({ target, update, signal }) => {
target.addEventListener('volumechange', update);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', update);
});
},
request: {
setVolume: (volume: number, { target }) => {
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
return volume;
},
setMuted: (muted: boolean, { target }) => {
target.muted = muted;
target.dispatchEvent(new Event('volumechange'));
return muted;
},
},
});
export function createTestStore() {
const store = createCoreStore({ slices: [audioSlice] });
const target = new MockMedia();
store.attach(target);
return { store, target };
}
// Async mock for testing pending states
export class AsyncMockMedia extends EventTarget {
volume = 1;
muted = false;
}
export const asyncAudioSlice = createSlice<AsyncMockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: ({ target, update, signal }) => {
const handler = () => update();
target.addEventListener('volumechange', handler);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', handler);
});
},
request: {
setVolume: {
handler: async (volume: number, { target }) => {
await Promise.resolve();
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
return volume;
},
},
failingRequest: {
handler: async () => {
await Promise.resolve();
throw new Error('Request failed');
},
},
},
});
export function createAsyncTestStore() {
const store = createCoreStore({
slices: [asyncAudioSlice],
onError: noop,
});
const target = new AsyncMockMedia();
store.attach(target);
return { store, target };
}
@@ -0,0 +1,116 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useMutation } from '../use-mutation';
import { createAsyncTestStore, createTestStore } from './test-utils';
describe('useMutation', () => {
it('returns mutation result with idle status initially', () => {
const { store } = createAsyncTestStore();
const { result } = renderHook(() => useMutation(store, 'setVolume'));
expect(result.current.status).toBe('idle');
expect(typeof result.current.mutate).toBe('function');
expect(typeof result.current.reset).toBe('function');
});
it('updates to success status after successful mutation', async () => {
const { store } = createAsyncTestStore();
const { result } = renderHook(() => useMutation(store, 'setVolume'));
await act(async () => {
await result.current.mutate(0.5);
});
expect(result.current.status).toBe('success');
if (result.current.status === 'success') {
expect(result.current.data).toBe(0.5);
}
});
it('updates to error status after failed mutation', async () => {
const { store } = createAsyncTestStore();
const { result } = renderHook(() => useMutation(store, 'failingRequest'));
await act(async () => {
try {
await result.current.mutate();
} catch {
// Expected to throw
}
});
expect(result.current.status).toBe('error');
if (result.current.status === 'error') {
expect(result.current.error).toBeInstanceOf(Error);
expect((result.current.error as Error).message).toBe('Request failed');
}
});
it('reset clears settled state', async () => {
const { store } = createAsyncTestStore();
const { result } = renderHook(() => useMutation(store, 'setVolume'));
await act(async () => {
await result.current.mutate(0.5);
});
expect(result.current.status).toBe('success');
await act(async () => {
result.current.reset();
});
expect(result.current.status).toBe('idle');
});
it('re-renders only when task status changes', async () => {
const { store } = createAsyncTestStore();
const renderCount = vi.fn();
const { result } = renderHook(() => {
renderCount();
return useMutation(store, 'setVolume');
});
expect(renderCount).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.mutate(0.5);
});
// Should have re-rendered for pending and success
expect(renderCount.mock.calls.length).toBeGreaterThan(1);
});
it('mutate function is stable across renders', () => {
const { store } = createAsyncTestStore();
const { result, rerender } = renderHook(() => useMutation(store, 'setVolume'));
const firstMutate = result.current.mutate;
rerender();
expect(result.current.mutate).toBe(firstMutate);
});
it('works with synchronous requests', async () => {
const { store } = createTestStore();
const { result } = renderHook(() => useMutation(store, 'setVolume'));
await act(async () => {
await result.current.mutate(0.5);
});
expect(result.current.status).toBe('success');
if (result.current.status === 'success') {
expect(result.current.data).toBe(0.5);
}
});
});
@@ -0,0 +1,37 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useRequest } from '../use-request';
import { createTestStore } from './test-utils';
describe('useRequest', () => {
it('returns request map', () => {
const { store } = createTestStore();
const { result } = renderHook(() => useRequest(store));
expect(result.current).toHaveProperty('setVolume');
expect(result.current).toHaveProperty('setMuted');
expect(typeof result.current.setVolume).toBe('function');
});
it('returns request by name', () => {
const { store } = createTestStore();
const { result } = renderHook(() => useRequest(store, 'setVolume'));
expect(typeof result.current).toBe('function');
});
it('returns stable reference', () => {
const { store } = createTestStore();
const { result, rerender } = renderHook(() => useRequest(store));
const firstRequest = result.current;
rerender();
expect(result.current).toBe(firstRequest);
});
});
@@ -0,0 +1,51 @@
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);
});
});
@@ -0,0 +1,31 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useTasks } from '../use-tasks';
import { createTestStore } from './test-utils';
describe('useTasks', () => {
it('returns tasks record', () => {
const { store } = createTestStore();
const { result } = renderHook(() => useTasks(store));
expect(result.current).toEqual({});
});
it('updates when task completes', async () => {
const { store } = createTestStore();
const { result } = renderHook(() => useTasks(store));
expect(result.current.setVolume).toBeUndefined();
await act(async () => {
await store.request.setVolume(0.5);
});
expect(result.current.setVolume).toBeDefined();
expect(result.current.setVolume?.status).toBe('success');
});
});
@@ -0,0 +1,98 @@
import type { EnsureFunction } from '@videojs/utils/types';
import type { Task } from '../../core/queue';
import type { AnyStore, InferStoreRequests } from '../../core/store';
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 (
* <>
* <select
* onChange={(e) => source.mutate({ src: e.target.value, type: 'video/mp4' })}
* disabled={source.status === 'pending'}
* >
* <option value="/videos/720p.mp4">720p</option>
* <option value="/videos/1080p.mp4">1080p</option>
* </select>
* {source.status === 'error' && (
* <p className="error">Failed to load: {String(source.error)}</p>
* )}
* </>
* );
* }
* ```
*/
export function useMutation<
Store extends AnyStore,
Name extends keyof InferStoreRequests<Store>,
Mutate extends InferStoreRequests<Store>[Name] = InferStoreRequests<Store>[Name],
>(store: Store, name: Name): MutationResult<Mutate, Awaited<ReturnType<EnsureFunction<Mutate>>>> {
type Data = Awaited<ReturnType<EnsureFunction<Mutate>>>;
const taskRef = useRef<Task | undefined>(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<Mutate, Data>;
}
export namespace useMutation {
export type Result<Mutate, Data> = MutationResult<Mutate, Data>;
}
@@ -0,0 +1,44 @@
import type { AnyStore, InferStoreRequests } from '../../core/store';
import { isUndefined } from '@videojs/utils/predicate';
/**
* Access the store's request methods.
*
* Returns either the full request map or a specific request function by name.
*
* The request map is stable across renders (same reference).
*
* @example
* ```tsx
* // Get all requests
* function Controls() {
* const request = useRequest(store);
* return <button onClick={() => request.play()}>Play</button>;
* }
*
* // Get a specific request by name
* function PlayButton() {
* const play = useRequest(store, 'play');
* return <button onClick={() => play()}>Play</button>;
* }
* ```
*/
export function useRequest<S extends AnyStore>(store: S): InferStoreRequests<S>;
export function useRequest<S extends AnyStore, Name extends keyof InferStoreRequests<S>>(
store: S,
name: Name,
): InferStoreRequests<S>[Name];
// eslint-disable-next-line react/no-unnecessary-use-prefix
export function useRequest<S extends AnyStore, Name extends keyof InferStoreRequests<S>>(
store: S,
name?: Name,
): InferStoreRequests<S> | InferStoreRequests<S>[Name] {
const request = store.request as InferStoreRequests<S>;
if (isUndefined(name)) {
return request;
}
return request[name];
}
@@ -0,0 +1,34 @@
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 <span>{Math.round(volume * 100)}%</span>;
* }
* ```
*/
export function useSelector<S extends AnyStore, T>(store: S, selector: (state: InferStoreState<S>) => 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);
}
@@ -0,0 +1,50 @@
import type { TasksRecord } from '../../core/queue';
import type { AnyStore, InferStoreTasks } from '../../core/store';
import { useCallback, useRef, useSyncExternalStore } from 'react';
/**
* 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
*
* @example
* ```tsx
* function TaskList() {
* const tasks = useTasks(store);
*
* return (
* <ul>
* {Object.entries(tasks).map(([name, task]) => (
* <li key={name}>
* {name}: {task.status}
* </li>
* ))}
* </ul>
* );
* }
* ```
*/
export function useTasks<S extends AnyStore>(store: S): TasksRecord<InferStoreTasks<S>> {
const tasksRef = useRef(store.queue.tasks);
const subscribe = useCallback(
(onStoreChange: () => void) =>
store.queue.subscribe((tasks) => {
tasksRef.current = tasks;
onStoreChange();
}),
[store],
);
const getSnapshot = useCallback(() => tasksRef.current as TasksRecord<InferStoreTasks<S>>, []);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
+1 -1
View File
@@ -3,4 +3,4 @@ export { useStoreContext } from './context';
export { createStore } from './create-store';
export type { CreateStoreConfig, CreateStoreResult, ProviderProps } from './create-store';
export { useRequest, useSelector, useTasks } from './hooks';
export { useMutation, useRequest, useSelector, useTasks } from './hooks';
@@ -190,11 +190,11 @@ describe('createStore', () => {
expect(result.current).toHaveProperty('setVolume');
});
it('returns selected request', () => {
it('returns request by name', () => {
const { Provider, useRequest, create } = createStore({ slices: [audioSlice] });
const store = create();
const { result } = renderHook(() => useRequest(r => r.setVolume), {
const { result } = renderHook(() => useRequest('setVolume'), {
wrapper: ({ children }: { children: ReactNode }) => <Provider store={store}>{children}</Provider>,
});
@@ -1,149 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { createSlice } from '../../core/slice';
import { createStore as createCoreStore } from '../../core/store';
import { useRequest, useSelector, useTasks } from '../hooks';
describe('react hooks', () => {
// Mock target
class MockMedia extends EventTarget {
volume = 1;
muted = false;
}
const audioSlice = createSlice<MockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: ({ target, update, signal }) => {
target.addEventListener('volumechange', update);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', update);
});
},
request: {
setVolume: (volume: number, { target }) => {
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
return volume;
},
setMuted: (muted: boolean, { target }) => {
target.muted = muted;
target.dispatchEvent(new Event('volumechange'));
return muted;
},
},
});
function createTestStore() {
const store = createCoreStore({ slices: [audioSlice] });
const target = new MockMedia();
store.attach(target);
return { store, target };
}
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);
});
});
describe('useRequest', () => {
it('returns request map', () => {
const { store } = createTestStore();
const { result } = renderHook(() => useRequest(store));
expect(result.current).toHaveProperty('setVolume');
expect(result.current).toHaveProperty('setMuted');
expect(typeof result.current.setVolume).toBe('function');
});
it('returns selected request', () => {
const { store } = createTestStore();
const { result } = renderHook(() => useRequest(store, r => r.setVolume));
expect(typeof result.current).toBe('function');
});
it('returns stable reference', () => {
const { store } = createTestStore();
const { result, rerender } = renderHook(() => useRequest(store));
const firstRequest = result.current;
rerender();
expect(result.current).toBe(firstRequest);
});
});
describe('useTasks', () => {
it('returns tasks record', () => {
const { store } = createTestStore();
const { result } = renderHook(() => useTasks(store));
expect(result.current).toEqual({});
});
it('updates when task completes', async () => {
const { store } = createTestStore();
const { result } = renderHook(() => useTasks(store));
expect(result.current.setVolume).toBeUndefined();
await act(async () => {
await store.request.setVolume(0.5);
});
expect(result.current.setVolume).toBeDefined();
expect(result.current.setVolume?.status).toBe('success');
});
});
});