Files
flyer-crawler.projectium.com/src/hooks/useApi.ts
Torben Sorensen 672e4ca597
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m56s
flyer upload (anon) issues
2025-12-30 21:53:36 -08:00

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