Search code examples
reactjsfetchreact-custom-hooksabortcontroller

React - using AbortController on every request as a custom hook


I have a context provider in my app:

export const FormContext = createContext<IFormContext | null>(null);

function FormProvider({ caseNumber, children, ...props }: PropsWithChildren<IFormProviderContextProps>) {
    const {
        data: { caseNumber, taxDocuments, roles },
        api,
    } = useApiData();
    const [error, setError] = useState<string>(null);
    const [searchParams, setSearchParams] = useSearchParams();
    const activeStep = searchParams.get("step");

    const setActiveStep = useCallback((x: number) => {
        searchParams.delete("steg");
        setSearchParams([...searchParams.entries(), ["step", Object.keys(STEPS).find((k) => STEPS[k] === x)]]);
    }, []);

    useEffect(() => {
        const abortController = new AbortController();
        if (case) api.getPersons(case, abortController.signal).catch((error) => setError(error.message));
        return () => {
            abortController.abort();
        };
    }, [case]);

    useEffect(() => {
        const abortController = new AbortController();
        if (activeStep === Stepper.INCOME) {
            api.getTaxDocuments(abortController.signal).catch((error) => setError(error.message));
        }
        return () => {
            abortController.abort();
        };
    }, [activeStep]);

    useEffect(() => {
        const abortController = new AbortController();
            
        api.getCase(caseNumber, abortController.signal).catch((error) => setError(error.message));
        }
        return () => {
            abortController.abort();
        };
    }, []);

    return (
        <FormContex.Provider value={{ taxDocuments, case, roles, activeStep, setActiveStep, error, ...props }}>
            {children}
        </FormContex.Provider>
    );
}

I am using this FormProvider as a wrapper for my FormPage:

<React.StrictMode>
    <BrowserRouter>
        <Routes>
            <Route path="/:caseNumber" element={<FormWrapper />} />
            <Route path="/" element={<div>Hello world</div>} />
        </Routes>
    </BrowserRouter>
</React.StrictMode>

function FormWrapper() {
    const { caseNumber } = useParams<{ caseNumber?: string }>();
    return (
        <FormProvider caseNumber={caseNumber}>
            <FormPage />
        </FormProvider>
    );
}

In my FormPage I display components based on the activeStep that I get from FromProvider

export default function FormWrapper({ activeStep, ...props }: FormWrapperProps) {
    const renderForm = useMemo(() => {
        switch (activeStep) {
            case Stepper.TIMELINE:
                return <Timeline {...props} />;
            case Stepper.INCOME:
                return <Income {...props} />;
            case Stepper.RESIDENCY:
                return <Residency {...props} />;
            case Stepper.SUMMARY:
                return <Summary {...props} />;
            default:
                return <Timeline {...props} />;
        }
    }, [activeStep]);

    return <Suspense fallback={<Loader size="3xlarge" title="loading..." />}>{renderForm}</Suspense>;
}

What I would like to do is to implement an abort controller if component gets unmounted to stop the fetch request and state update. I have tried that with implementing it inside useEffect functions of the FormProvider. But, that is repetitive and would like to make some kind of function or a hook that would set the abort controller to every request. I am not sure how to do that with the current setup, where I have my api calls defined in useApiData() hook which looks like this:

export const useApiData = () => {
    const [case, setCase] = useState<CaseDto>(null);
    const [taxDocuments, setTaxDocuments] = useState<TaxDocumentsResponse[]>([]);
    const [roles, setRoles] = useState<IRoleUi[]>([]);

    const getCase = async (caseNumber: string, signal?: AbortSignal) => {
        const case = await CASE_API.case.findMetadataForCase(caseNumber, { signal });
        setCase(case.data);
    };

    const getPersons = async (case: CaseDto, signal?: AbortSignal) => {
        const personPromises = case.roles.map((role) =>
            PERSON_API.information.getPersonPost(
                { id: role.id },
                { signal }
            )
        );
        const [...persons] = await Promise.all([...personPromises]);
        const roles = persons.map((person) => {
            const role = case.roles.find((role) => role.id === person.data.id);
            if (!role) throw new Error(PERSON_NOT_FOUND);
            return { ...role, ...person.data };
        });

        setRoles(roles);
    };

    const getTaxDocuments = async (signal?: AbortSignal) => {
        const taxDocumentsDtoPromises = [getFullYear() - 1, getFullYear() - 2, getFullYear() - 3].map((year) =>
            TAX_API.integration.getTaxDocument(
                {
                    year: year.toString(),
                    filter: "",
                    personId: "123",
                },
                { signal }
            )
        );
        const [taxDocument1, taxDocument2, taxDocument3] = await Promise.all([...taxDocumentsDtoPromises]);
        setTaxDocuments([taxDocument1.data, taxDocument2.data, taxDocument3.data]);
    };




    const api = {
        getCase,
        getPersons,
        getTaxDocuments,
    };

    const data = {
        case,
        roles,
        taxDocuments,
    };

    return { data, api };
}

As I said I would like to be able to call api without having to define abort controller in every useEffect hook, but I am not sure how to achieve some like this for example:

apiWithAbortController.getCase(caseNumber).catch((error) => setError(error.message))}

I have tried with using a custom hook like this:

export const useAbortController = () => {
    const abortControllerRef = useRef<AbortController>();

    useEffect(() => {
        return () => abortControllerRef.current?.abort();
    }, []);

    const getSignal = useCallback(() => {
        if (!abortControllerRef.current) {
            abortControllerRef.current = new AbortController();
        }
        return abortControllerRef.current.signal;
    }, []);

    return getSignal;
};

That I was using like this in my useApiData:

const signalAbort = useAbortController();

const getCase = async (caseNumber: string) => {
    const case = await CASE_API.case.findMetadataForCase(caseNumber, { signal: signalAbort() });
    setCase(case.data);
};

But, that didn't work, with that setup none of the fetch calls were made.


Solution

  • There is no way to cancel all the network requests globallly at once. you have to attach an abort controller to each fetch calls.

    import { useEffect } from 'react';
    
    export const useAbortController = (fetcher,args,dependencies) => {
      useEffect(() => {
        const abortController = new AbortController();
        const signal = abortController.signal;
        // fetch here. write a reusable form based on your api function
        fetcher(...args,{signal})
    
        // you could also setTimeout and maybe after 2 seconds call abortController.abort()
          return () => abortController.abort();
      }, [...dependencies]);
    };