import { useCallback, useEffect, useReducer, useRef } from 'react'; export interface State { data?: T; error?: Error; loading?: boolean; } enum ActionType { LOADING = 'LOADING', ERROR = 'ERROR', FETCHED = 'FETCHED', } // discriminated union type type Action = | { type: ActionType.LOADING } | { type: ActionType.FETCHED; payload: T } | { type: ActionType.ERROR; error: Error }; export const useFetch = ( url: string, options?: RequestInit, initialFetch = true, skip?: boolean ): { state: State; refetch: ( params?: Record | undefined, body?: BodyInit ) => Promise; } => { // Used to prevent state update if the component is unmounted const cancelRequest = useRef(false); const initialState: State = { error: undefined, data: undefined, loading: false, }; // Keep state logic separated const fetchReducer = (state: State, action: Action): State => { switch (action.type) { case ActionType.LOADING: return { ...initialState, loading: true }; case ActionType.FETCHED: return { ...initialState, data: action.payload, loading: false }; case ActionType.ERROR: return { ...initialState, error: action.error, loading: false }; } }; const [state, dispatch] = useReducer(fetchReducer, initialState); const fetchCallback = useCallback( async ( params?: Record, body?: BodyInit ) => { if (!url) return; const fetchData = async () => { dispatch({ type: ActionType.LOADING }); let data; try { const assembledUrl = new URL(url); if (params) { for (const [key, value] of Object.entries(params)) { if (value) { assembledUrl.searchParams.set(key, value.toString()); } } } const response = await fetch(assembledUrl.toString(), { ...options, body: body ? body : options?.body, }); if (!response.ok) { throw new Error(response.statusText); } data = (await response.json()) as T; // @ts-ignore - 'error' in data if (data && 'error' in data) { // @ts-ignore - data.error throw new Error(data.error); } if (cancelRequest.current) return; dispatch({ type: ActionType.FETCHED, payload: data }); } catch (error) { if (cancelRequest.current) return; dispatch({ type: ActionType.ERROR, error: error as Error }); } return data; }; return fetchData(); }, // Do nothing if the url is not given // eslint-disable-next-line react-hooks/exhaustive-deps [url] ); useEffect(() => { cancelRequest.current = false; if (initialFetch && !skip) { fetchCallback(); } }, [fetchCallback, initialFetch, skip, url]); useEffect(() => { // Use the cleanup function for avoiding a possibly... // ...state update after the component was unmounted return () => { cancelRequest.current = true; }; }, []); return { state, refetch: fetchCallback, }; };