React Hook Dependencies - Generic Fetch Hook
Asked Answered
E

2

12

I've followed many tutorials for how to set up my own custom generic useFetch hook. What I came up with works well, but it is breaking some Rules of Hooks. Mostly, it doesn't use the "correct" set of dependencies.

The generic hook accepts a url, options, and dependencies. Setting the dependencies up as all three creates an infinite refresh loop, even though the dependencies aren't changing.

// Infinite useEffect loop - happy dependencies
const UseRequest: <T>(url: string, options?: Partial<UseRequestOptions> | undefined, dependencies?: any[]) => UseRequestResponse<T>
 = <T>(url: string, options: Partial<UseRequestOptions> | undefined = undefined, dependencies: any[] = []): UseRequestResponse<T> => {
    const [data, setData] = useState<T | undefined>();
    const [loading, setLoading] = useState<boolean>(false);
    const [error, setError] = useState<UseRequestError | undefined>();

    useEffect(() => {
        let ignore = false;
        (async () => {
            try {
                setLoading(true);
                const response = await (options ? fetch(url) : fetch(url, options))
                    .then(res => res.json() as Promise<T>);
                if (!ignore) setData(response);
            } catch (err) {
                setError(err);
            } finally {
                setLoading(false);
            }
        })();
        return (() => { ignore = true; });
    }, [url, options, dependencies]);
    return { data, loading, error };
}

I've found that it works as expected if I omit the options from dependencies (which sort of makes sense as we don't expect this deep object to change in a way we should monitor) and spread the incoming dependencies. Of course, both of these changes break the "Rules of Hooks."

// Working - mad dependencies
const UseRequest: <T>(url: string, options?: Partial<UseRequestOptions> | undefined, dependencies?: any[]) => UseRequestResponse<T>
 = <T>(url: string, options: Partial<UseRequestOptions> | undefined = undefined, dependencies: any[] = []): UseRequestResponse<T> => {
    const [data, setData] = useState<T | undefined>();
    const [loading, setLoading] = useState<boolean>(false);
    const [error, setError] = useState<UseRequestError | undefined>();

    useEffect(() => {
        let ignore = false;
        (async () => {
            try {
                setLoading(true);
                const response = await (options ? fetch(url) : fetch(url, options))
                    .then(res => res.json() as Promise<T>);
                if (!ignore) setData(response);
            } catch (err) {
                setError(err);
            } finally {
                setLoading(false);
            }
        })();
        return (() => { ignore = true; });
    }, [url, ...dependencies]);
    return { data, loading, error };
}

...which I then use like

export const GetStuff: () => UseRequestResponse<Stuff[]> & { refresh: () => void } = () => {
    const { appToken } = GetAppToken();
    const [refreshIndex, setRefreshIndex] = useState(0);
    return {
        ...UseRequest<Stuff[]>('https://my-domain.api/v1/stuff', {
            method: 'GET',
            headers: {
                'Authorization': `Bearer ${appToken}`
            }
        }, [appToken, refreshIndex]),
        refresh: () => setRefreshIndex(refreshIndex + 1),
    };
};

Notice, the only change between the working and broken states was:

}, [url, options, dependencies]);

...to:

}, [url, ...dependencies]);

So, how could I possibly rewrite this to follow the Rules of Hooks and not fall into an infinite refresh loop?

Here is the full code for useRequest with the defined interfaces:

import React, { useState, useEffect } from 'react';

const UseRequest: <T>(url: string, options?: Partial<UseRequestOptions> | undefined, dependencies?: any[]) => UseRequestResponse<T>
 = <T>(url: string, options: Partial<UseRequestOptions> | undefined = undefined, dependencies: any[] = []): UseRequestResponse<T> => {
    const [data, setData] = useState<T | undefined>();
    const [loading, setLoading] = useState<boolean>(false);
    const [error, setError] = useState<UseRequestError | undefined>();

    useEffect(() => {
        let ignore = false;
        (async () => {
            try {
                setLoading(true);
                const response = await (options ? fetch(url) : fetch(url, options))
                    .then(res => res.json() as Promise<T>);
                if (!ignore) setData(response);
            } catch (err) {
                setError(err);
            } finally {
                setLoading(false);
            }
        })();
        return (() => { ignore = true; });
    }, [url, ...dependencies]);
    return { data, loading, error };
}

export default UseRequest;

export interface UseRequestOptions {
    method: string;
    mode: 'cors', // no-cors, *cors, same-origin
    cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
    credentials: 'same-origin', // include, *same-origin, omit
    headers: {
        [prop: string]: string;
    },
    redirect: string, // manual, *follow, error
    referrerPolicy: string, // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
    body: string | { [prop: string]: any };
    [prop: string]: any;
};

export interface UseRequestError {
    message: string;
    error: any;
    code: string | number;
    [prop: string]: any;
}

export interface UseRequestResponse<T> {
    data: T | undefined;
    loading: boolean;
    error: Partial<UseRequestError> | undefined;
}
Engine answered 16/10, 2020 at 16:58 Comment(0)
T
5

That's because you recreate a new array on each render. In fact the whole dependency makes no sense since you never use it inside the effect.

You could equally rely on the options object, which has changing headers. But since the object also gets recreated on each render you have to memoize it first:

export const GetStuff: () => UseRequestResponse<Stuff[]> & { refresh: () => void } = () => {
    const { appToken } = GetAppToken();
    const [refreshIndex, setRefreshIndex] = useState(0);

    const options = useMemo(() => ({
        method: 'GET',
        headers: {
            'Authorization': `Bearer ${appToken}`
        }
    }), [appToken, refreshIndex])

    return {
        ...UseRequest<Stuff[]>('https://my-domain.api/v1/stuff', options),
        refresh: () => setRefreshIndex(refreshIndex + 1),
    };
};

Then, instead of relying on the refresh index to trigger a refresh you could have the useRequest() hook return a refresh function, which internally also calls that function in the effect (instead of putting the load logic in the effect itself, it just calls that function). This way you follow the rules even better, since the useMemo never actually depends on the refresh index so it shouldn't be in the dependencies.

Tutti answered 19/10, 2020 at 21:56 Comment(1)
I can tell what they are trying to do with the dependencies variable which is to trigger a re-fetch. But you have resolved it in a much better way by using those dependencies to re-instantiate the options object and using the options object as the dependency for the fetch. Great answer.Cargian
R
0

I guess I don't understand why you are using an effect to get this. What I've been doing in my latest project is using the outer hook to pull in the fetch method and then I can call that method however I need when I need the data. If you need a constant reference to the data stream you can use react SWR, here's an example of both.

import { useCallback } from 'react'

export function useGetUserFromCoverage() {
const getUserFromCoverage = useCallback(
        async (url) => {
            const result = await fetch(url)
            return result
},
        []
    )

    return getUserFromCoverage
}

import { useCallback } from 'react'
import useSWR from 'swr'

export function useGetHubUser(url) {
    const getHubUser = useCallback(
        async (url: string) => {
            const result = await fetch(url)
            return result
        },
        []
    )

    const { data, error } = useSWR(url, getHubUser)

    return { fetch: { data, error } }
}
Renita answered 22/10, 2020 at 12:31 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.