import { useCallback, useEffect, useMemo } from 'react'; import { PlainObject } from './types'; import { useIsMounted } from './useIsMounted'; import { useMemoizedValue } from './useMemoizedValue'; import { usePrevious } from './usePrevious'; import { useSetState } from './useSetState'; import { isPlainObject, isURL } from './utils'; export const USE_FETCH_STATUS = { IDLE: 'IDLE', LOADING: 'LOADING', SUCCESS: 'SUCCESS', ERROR: 'ERROR', } as const; interface UseFetchError extends Error { response?: unknown; status?: number; } interface UseFetchState { data?: TDataType; error?: UseFetchError; isCached: boolean; retryCount: number | null; status: UseFetchStatus; url: string; } export type UseFetchStatus = keyof typeof USE_FETCH_STATUS; export interface UseFetchOptions { body?: BodyInit | Record; /** * Time to cache the request if provided. * When the cache is expired, the request will be triggered again. */ cacheTTL?: number; headers?: PlainObject; /** * HTTP method. * @default: 'GET' */ method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE'; /** * Request mode. * @default: 'cors' */ mode?: 'cors' | 'navigate' | 'no-cors' | 'same-origin'; /** Callback fired when an error occurs */ onError?: (error: UseFetchError) => void; /** Callback fired when the request completes (success or error) */ onFinally?: () => void; /** Callback fired when the loading state changes */ onLoading?: () => void; /** Callback fired when data is successfully fetched */ onSuccess?: (data: TDataType) => void; /** * Number of retries. */ retries?: number; /** * Time to wait before retrying. * A function like attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000) applies exponential backoff. * A function like attempt => attempt * 1000 applies linear backoff. * @default: attempt => attempt * 1000 */ retryDelay?: number | ((attempt: number) => number); /** * Request type. * @default: 'json' */ type?: 'json' | 'urlencoded'; url: string; /** * Wait for the user to trigger the request. * @default: false */ wait?: boolean; } export interface UseFetchResult extends UseFetchState { /** * Whether the response is cached. */ isCached: boolean; isError: () => boolean; isFetched: () => boolean; isLoading: () => boolean; isPaused: () => boolean; isSuccess: () => boolean; refetch: (eraseData?: boolean) => void; } const globalCache = new Map(); async function request(options: UseFetchOptions): Promise { const { body = undefined, headers = {}, method = 'GET', mode = 'cors', type = 'json', url = '', } = options; const contentTypes = { json: 'application/json', urlencoded: 'application/x-www-form-urlencoded', }; const params: RequestInit = { body: undefined, cache: 'no-store' as const, headers: { Accept: 'application/json', 'Content-Type': contentTypes[type], ...headers, }, method, mode, credentials: undefined, }; if (body) { params.body = ( isPlainObject(body) && type === 'json' ? JSON.stringify(body) : body ) as BodyInit; } try { const response = await fetch(url, params); let content: unknown; try { content = await response.json(); } catch { content = await response.text(); } if (response.status > 299) { const error = new Error(response.statusText) as UseFetchError; error.status = response.status; error.response = content; throw error; } return content; } catch (error) { // Handle hard failures (network issues, CORS errors, fetch rejection) const fetchError = new Error('Network request failed') as UseFetchError; fetchError.status = 0; // Status 0 indicates a network error fetchError.response = error instanceof Error ? error.message : error; throw fetchError; } } export const useFetchCache = { /** * Retrieve data from the cache. * @param url The URL to retrieve data for. * @returns Cached data or `undefined` if not found or expired. */ get(url: string) { const cached = globalCache.get(url); if (cached && Date.now() < cached.expiry) { return cached; } globalCache.delete(url); // Remove expired entry return undefined; }, /** * Set data in the cache. * @param url The URL to cache data for. * @param data The data to cache. * @param ttl Time-to-live in milliseconds. */ set(url: string, data: any, ttl: number) { globalCache.set(url, { data, expiry: Date.now() + ttl }); }, /** * Clear the cache. * @param url Optional URL to clear. Clears all cache if not specified. */ clear(url?: string) { if (url) { globalCache.delete(url); } else { globalCache.clear(); } }, /** * Check if a URL is cached and still valid. * @param url The URL to check. * @returns `true` if cached and valid, `false` otherwise. */ has(url: string) { const cached = globalCache.get(url); return !!cached && Date.now() < cached.expiry; }, }; export function useFetch( urlOrOptions: string | UseFetchOptions, ): UseFetchResult { const { cacheTTL = 0, onError, onFinally, onLoading, onSuccess, retries = 0, retryDelay = (attempt: number) => attempt * 1000, wait = false, ...options } = useMemoizedValue( (isURL(urlOrOptions) ? ({ type: 'json', url: urlOrOptions } as const) : urlOrOptions) as UseFetchOptions, ); const [{ data, error, isCached, retryCount, status, url }, setState] = useSetState< UseFetchState >({ data: undefined, error: undefined, isCached: false, retryCount: null, status: USE_FETCH_STATUS.IDLE, url: options.url, }); const isMounted = useIsMounted(); const previousRetryCount = usePrevious(retryCount); if (!isPlainObject(options) || !isURL(url)) { throw new Error('Expected an options object or URL'); } const getData = useCallback( (eraseData?: boolean) => { setState(s => ({ data: eraseData ? undefined : s.data, error: undefined, isCached: false, status: USE_FETCH_STATUS.LOADING, })); onLoading?.(); // Check cache if (cacheTTL && useFetchCache.has(url) && !eraseData) { const cached = useFetchCache.get(url); if (cached) { setState({ data: cached.data, error: undefined, isCached: true, status: USE_FETCH_STATUS.SUCCESS, }); onSuccess?.(cached.data); onFinally?.(); return; } } // eslint-disable-next-line promise/catch-or-return request({ ...options }) .then(response => { if (!isMounted()) { return; } if (cacheTTL) { useFetchCache.set(url, response, cacheTTL); } setState({ data: response, retryCount: null, status: USE_FETCH_STATUS.SUCCESS, }); onSuccess?.(response); }) .catch(responseError => { if (!isMounted()) { return; } const counter = retryCount ?? 0; if (!retries || counter >= retries) { setState({ error: responseError, status: USE_FETCH_STATUS.ERROR, }); onError?.(responseError); } if (retries && counter < retries) { setState({ retryCount: counter + 1, }); } }) .finally(() => { if (isMounted()) { onFinally?.(); } }); }, [ cacheTTL, isMounted, onError, onFinally, onLoading, onSuccess, options, retries, retryCount, setState, url, ], ); useEffect(() => { if (url !== options.url) { setState({ data: undefined, error: undefined, status: USE_FETCH_STATUS.IDLE, url: options.url, }); } }, [options.url, setState, url]); useEffect(() => { if (status === USE_FETCH_STATUS.IDLE && !wait) { getData(); } }, [getData, status, wait]); useEffect(() => { if (retries && typeof retryCount === 'number' && retryCount !== previousRetryCount) { setTimeout(getData, typeof retryDelay === 'function' ? retryDelay(retryCount) : retryDelay); } }, [getData, previousRetryCount, retries, retryCount, retryDelay]); const isError = useCallback(() => status === USE_FETCH_STATUS.ERROR, [status]); const isFetched = useCallback( () => ([USE_FETCH_STATUS.SUCCESS, USE_FETCH_STATUS.ERROR] as UseFetchStatus[]).includes(status), [status], ); const isLoading = useCallback(() => status === USE_FETCH_STATUS.LOADING, [status]); const isPaused = useCallback(() => status === USE_FETCH_STATUS.IDLE && wait, [status, wait]); const isSuccess = useCallback(() => status === USE_FETCH_STATUS.SUCCESS, [status]); const refetch = useCallback((eraseData = false) => getData(eraseData), [getData]); return useMemo( () => ({ data, error, isCached, isError, isFetched, isLoading, isPaused, isSuccess, refetch, retryCount, status, url, }), [ data, error, isCached, isError, isFetched, isLoading, isPaused, isSuccess, refetch, retryCount, status, url, ], ); }