All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m56s
149 lines
5.8 KiB
TypeScript
149 lines
5.8 KiB
TypeScript
// src/hooks/useApi.ts
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { logger } from '../services/logger.client';
|
|
import { notifyError } from '../services/notificationService';
|
|
|
|
|
|
/**
|
|
* A custom React hook to simplify API calls, including loading and error states.
|
|
* It is designed to work with apiClient functions that return a `Promise<Response>`.
|
|
*
|
|
* @template T The expected data type from the API's JSON response.
|
|
* @template A The type of the arguments array for the API function.
|
|
* @param apiFunction The API client function to execute.
|
|
* @returns An object containing:
|
|
* - `execute`: A function to trigger the API call.
|
|
* - `loading`: A boolean indicating if the request is in progress.
|
|
* - `isRefetching`: A boolean indicating if a non-initial request is in progress.
|
|
* - `error`: An `Error` object if the request fails, otherwise `null`.
|
|
* - `data`: The data returned from the API, or `null` initially.
|
|
* - `reset`: A function to manually reset the hook's state to its initial values.
|
|
*/
|
|
export function useApi<T, TArgs extends unknown[]>(
|
|
apiFunction: (...args: [...TArgs, AbortSignal?]) => Promise<Response>,
|
|
) {
|
|
const [data, setData] = useState<T | null>(null);
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
const [isRefetching, setIsRefetching] = useState<boolean>(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const hasBeenExecuted = useRef(false);
|
|
const lastErrorMessageRef = useRef<string | null>(null);
|
|
const abortControllerRef = useRef<AbortController>(new AbortController());
|
|
|
|
// Use a ref to track the latest apiFunction. This allows us to keep `execute` stable
|
|
// even if `apiFunction` is recreated on every render (common with inline arrow functions).
|
|
const apiFunctionRef = useRef(apiFunction);
|
|
|
|
useEffect(() => {
|
|
apiFunctionRef.current = apiFunction;
|
|
}, [apiFunction]);
|
|
|
|
// This effect ensures that when the component using the hook unmounts,
|
|
// any in-flight request is cancelled.
|
|
useEffect(() => {
|
|
const controller = abortControllerRef.current;
|
|
return () => {
|
|
controller.abort();
|
|
};
|
|
}, []);
|
|
|
|
/**
|
|
* Resets the hook's state to its initial values.
|
|
* This is useful for clearing data when dependencies change.
|
|
*/
|
|
const reset = useCallback(() => {
|
|
setData(null);
|
|
setLoading(false);
|
|
setIsRefetching(false);
|
|
setError(null);
|
|
}, []);
|
|
|
|
const execute = useCallback(
|
|
async (...args: TArgs): Promise<T | null> => {
|
|
setLoading(true);
|
|
setError(null);
|
|
lastErrorMessageRef.current = null;
|
|
if (hasBeenExecuted.current) {
|
|
setIsRefetching(true);
|
|
}
|
|
|
|
try {
|
|
const response = await apiFunctionRef.current(...args, abortControllerRef.current.signal);
|
|
|
|
if (!response.ok) {
|
|
// Attempt to parse a JSON error response. This is aligned with ADR-003,
|
|
// which standardizes on structured Zod errors.
|
|
let errorMessage = `Request failed with status ${response.status}: ${response.statusText}`;
|
|
try {
|
|
const errorData = await response.json();
|
|
// If the backend sends a Zod-like error array, format it.
|
|
if (Array.isArray(errorData.issues) && errorData.issues.length > 0) {
|
|
errorMessage = errorData.issues
|
|
.map(
|
|
(issue: { path?: string[]; message: string }) =>
|
|
`${issue.path?.join('.') || 'Error'}: ${issue.message}`,
|
|
)
|
|
.join('; ');
|
|
} else if (errorData.message) {
|
|
errorMessage = errorData.message;
|
|
}
|
|
} catch {
|
|
/* Ignore JSON parsing errors and use the default status text message. */
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// Handle successful responses with no content (e.g., HTTP 204).
|
|
if (response.status === 204) {
|
|
setData(null);
|
|
return null;
|
|
}
|
|
|
|
const result: T = await response.json();
|
|
setData(result);
|
|
if (!hasBeenExecuted.current) {
|
|
hasBeenExecuted.current = true;
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
let err: Error;
|
|
if (e instanceof Error) {
|
|
err = e;
|
|
} else if (typeof e === 'object' && e !== null && 'status' in e) {
|
|
// Handle structured errors (e.g. { status: 409, body: { ... } })
|
|
const structuredError = e as { status: number; body?: { message?: string } };
|
|
const message = structuredError.body?.message || `Request failed with status ${structuredError.status}`;
|
|
err = new Error(message);
|
|
} else {
|
|
err = new Error('An unknown error occurred.');
|
|
}
|
|
// If the error is an AbortError, it's an intentional cancellation, so we don't set an error state.
|
|
if (err.name === 'AbortError') {
|
|
logger.info('API request was cancelled.', { functionName: apiFunction.name });
|
|
return null;
|
|
}
|
|
logger.error('API call failed in useApi hook', {
|
|
error: err.message,
|
|
functionName: apiFunction.name,
|
|
});
|
|
// Only set a new error object if the message is different from the last one.
|
|
// This prevents creating new object references for the same error (e.g. repeated timeouts)
|
|
// and helps break infinite loops in components that depend on the `error` object.
|
|
if (err.message !== lastErrorMessageRef.current) {
|
|
setError(err);
|
|
lastErrorMessageRef.current = err.message;
|
|
}
|
|
notifyError(err.message); // Optionally notify the user automatically.
|
|
return null; // Return null on failure.
|
|
} finally {
|
|
setLoading(false);
|
|
setIsRefetching(false);
|
|
}
|
|
},
|
|
[], // execute is now stable because it uses apiFunctionRef
|
|
); // abortControllerRef is stable
|
|
|
|
return { execute, loading, isRefetching, error, data, reset };
|
|
}
|