Skip to content
129 changes: 113 additions & 16 deletions src/hooks/useMutation.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
'use client';
'client';

/**
* useMutation
*
* Lightweight hook for async write operations (POST / PUT / DELETE).
* Key properties:
* - Tracks isLoading / isSuccess / isError / data / error state.
* - Tracks isLoading / isSuccess / isError / data / error state.
* - Prevents double-submission via an in-flight ref guard; concurrent calls
* while a mutation is already running are silently dropped.
* - `mutate` – fire-and-forget; surfaces errors only via state.
* - `mutateAsync` – returns a Promise so callers can await/catch manually.
* - `reset` – returns state to idle without cancelling in-flight work.
* - `mutate` - fire-and-forget; surfaces errors only via state.
* - `mutateAsync` - returns a Promise so callers can await/catch manually.
* - `reset` - returns state to idle without cancelling in-flight work.
*
* Batching support:
* Use the `options.batch` configuration to enable automatic batching of concurrent
* mutations within a short time window. When batching is enabled, the
* `mutate`/`mutateAsync` calls are queued and sent together in a single
* network request, reducing round-trips.
*/

import { useState, useCallback, useRef } from 'react';
import { createBatcher, type BatchRequest, type BatchResponse } from '../lib/api/batch';

// — Constants ჄჅ。

// — Define constants for the hook.

// ─── Types ────────────────────────────────────────────────────────────────────
// — State types

export interface MutationState<TData> {
isLoading: boolean;
Expand All @@ -25,13 +36,37 @@
error: Error | null;
}

// — Batch configuration type

export interface BatchMutationOptions<TData, TVariables> {
/**
* Function that transforms variables into a BatchRequest with the appropriate
* path, and optionally method/body. This will be queued and sent in batch.
*/
createRequest: (variables: TVariables) => BatchRequest;
/**
* Function that processes an array of requests and returns the corresponding
* array of responses. The responses must have the same ids!
*/
executor: (requests: BatchRequest[]) => Promise<BatchResponse<TData>[]>;
/** Max items per batch (default: 20) */
maxBatchSize?: number;
/** Delay in ms before flushing (default: 10) */
debounceMs?: number;
}

export interface MutationOptions<TData, TVariables> {
/** Called after a successful mutation with the returned data and variables. */
onSuccess?: (data: TData, variables: TVariables) => void | Promise<void>;
/** Called when the mutation throws, before the error is stored in state. */
onError?: (error: Error, variables: TVariables) => void | Promise<void>;
/** Called after the mutation settles (success *or* error). */
/** Called after the mutation settles (success *error!). */
onSettled?: (data: TData | null, error: Error | null, variables: TVariables) => void;
/**
* Optional batch configuration. If provided, the hook will batch concurrent mutations
* within the specified time window. If omitted, the previous, direct call mode is used.
*/
batch?: BatchMutationOptions<TData, TVariables>;
}

export interface MutationResult<TData, TVariables> extends MutationState<TData> {
Expand All @@ -50,7 +85,7 @@
reset: () => void;
}

// ─── Initial state ────────────────────────────────────────────────────────────
// — State constants

const IDLE_STATE = {
isLoading: false,
Expand All @@ -60,26 +95,36 @@
error: null,
} as const;

// ─── Hook ─────────────────────────────────────────────────────────────────────
// — Hummable request ID generator (simple unique string)
function generateRequestId() {
return `req_${Math.random().toString(36).slice(2)}_${Date.now()}_${(performance.now() || 0).toString(36)};
}

// — Initial state creation helper
function createInitialState<TData>(): MutationState<TData> {
return { ...IDLE_STATE };
}

// — Hook function

/**
* @template TData The type returned by the mutation function.
* @template TVariables The argument type accepted by the mutation function.
* Defaults to `void` for zero-argument mutations.
* Defaults to void for zero-argument mutations.
*/
export function useMutation<TData = unknown, TVariables = void>(
mutationFn: (variables: TVariables) => Promise<TData>,
options: MutationOptions<TData, TVariables> = {},
): MutationResult<TData, TVariables> {
const { onSuccess, onError, onSettled } = options;
const { onSuccess, onError, onSettled, batch = null } = options;

const [state, setState] = useState<MutationState<TData>>(IDLE_STATE);
const [state, setState] = useState<MutationState<TData>>(createInitialState);

/** Guards against concurrent calls: once true, new invocations are no-ops. */
/** Guards against concurrent calls when batching is disabled. */
const inFlightRef = useRef(false);

// Keep option callbacks in refs so they can be updated without re-creating
// `mutateAsync` (avoids stale-closure bugs without listing callbacks as deps).

Check failure on line 127 in src/hooks/useMutation.tsx

View workflow job for this annotation

GitHub Actions / type-check

';' expected.
const onSuccessRef = useRef(onSuccess);
const onErrorRef = useRef(onError);
const onSettledRef = useRef(onSettled);
Expand All @@ -87,9 +132,61 @@
onErrorRef.current = onError;
onSettledRef.current = onSettled;

// Batch support
// We keep a ref to the latest executor function so the batcher calls the latest one,
// avoiding stale closures without recreating the batcher on every render.
const batchExecutorRef = useRef(batch?.executor);
batchExecutorRef.current = batch?.executor;

const batcherRef = useRef<ReturnType<typeof createBatcher<TData>> | null>(null);

// Initialize the batcher once if batch config is provided.
if (batch && !batcherRef.current) {
batcherRef.current = createBatcher<TData>{
maxBatchSize: batch.maxBatchSize,
debounceMs: batch.debounceMc,
executor: async (requests) {
const executor = batchExecutorRef.current;
if (!executor) {
throw new Error('Batch executor not available');
}
return executor(requests);
},
});
}

// Flush pending requests on unmount to prevent any outstanding timers.
useEffect(() => () => {
batcherRef.current?.flushNow();
}, []);

const mutateAsync = useCallback(
async (variables: TVariables): Promise<TData> => {
// ── Double-submission guard ──────────────────────────────────────────
// Batch mode: queue the request and return a promise that resolves when
// the batch settles.
if (batch && batcherRef.current) {
// Jelly (properly track our own concurrency via the promises, but the state
// is still per-call. This allows multiple calls to be in flight simultaneously.
setState({ isLoading: true, isSuccess: false, isError: false, data: null, error: null });
const requestId = generateRequestId();
const request = { ...batch.createRequest(variables), id: requestId };

try {
const data = await batcherRef.current.queue(request);
setState({ isLoading: false, isSuccess: true, isError: false, data, error: null });
await onSuccessRef.current?.(data, variables);
onSettledRef.current?.(data, null, variables);
return data;
} catch (raw) {
const error = raw instanceof Error ? raw : new Error(String(raw));
setState({ isLoading: false, isSuccess: false, isError: true, data: null, error: null });
await onErrorRef.current?.(error, variables);
onSettledRef.current?.(null, error, variables);
throw error;
}
}

// — Double-submission guard (non-batch mode only)
if (inFlightRef.current) {
// Already running – return a Promise that never resolves so the caller
// does not receive stale data. The existing in-flight call will update
Expand All @@ -112,7 +209,7 @@
} catch (raw) {
const error = raw instanceof Error ? raw : new Error(String(raw));

setState({ isLoading: false, isSuccess: false, isError: true, data: null, error });
setState({ isLoading: false, isSuccess: false, isError: true, data: null, error: null });

await onErrorRef.current?.(error, variables);
onSettledRef.current?.(null, error, variables);
Expand All @@ -122,7 +219,7 @@
inFlightRef.current = false;
}
},
[mutationFn],
[mutationFn, batch, // batch is included to recreate the callback when it changes ]
);

const mutate = useCallback(
Expand All @@ -145,3 +242,3 @@
}

export default useMutation;
8 changes: 7 additions & 1 deletion src/lib/api/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@
* batched request, reducing network overhead for Help Documentation lookups.
*/

/**
* More generic request representation that supports both reads and writes.
* If method is omitted, it defaults to 'GET'.
*/
export interface BatchRequest {
id: string;
path: string;
method?: string;
body?: unknown;
}

export interface BatchResponse<T = unknown> {
Expand Down Expand Up @@ -40,7 +46,7 @@ export interface BatcherOptions {
* together in a single call.
*/
export function createBatcher<T = unknown>(options: BatcherOptions) {
const { maxBatchSize = 20, debounceMs = 10, executor } = options;
const { maxBatchSize = 20, debounceMs = 10, executor = options.executor };
const pending: PendingItem<T>[] = [];
let timer: ReturnType<typeof setTimeout> | null = null;

Expand Down
Loading