Search code examples
reactjstypescriptreact-hooksfetchuse-effect

React hook arguments cause infinite rerendering


I've got two custom react hooks one is just a wrapper for the other one.

The first one is a basic fetch hook, which returns the status and result or error.

interface State<T> {
  status: 'idle' | 'pending' | 'error' | 'success';
  data?: T;
  error?: string;
}

interface Cache<T> {
  [url: string]: T;
}

type Action<T> =
  | { type: 'request' }
  | { type: 'success'; payload: T }
  | { type: 'failure'; payload: string };

export function useFetch<T = unknown>(
  url: string,
  options?: RequestInit,
  cached = false
): State<T> {
  const cache = useRef<Cache<T>>({});
  const cancelRequest = useRef(false);

  const initialState: State<T> = {
    status: 'idle',
    error: undefined,
    data: undefined,
  };

  const fetchReducer = (state: State<T>, action: Action<T>): State<T> => {
    switch (action.type) {
      case 'request':
        return { ...initialState, status: 'pending' };
      case 'success':
        return { ...initialState, status: 'success', data: action.payload };
      case 'failure':
        return { ...initialState, status: 'error', error: action.payload };
      default:
        return state;
    }
  };

  const [state, dispatch] = useReducer(fetchReducer, initialState);

  useEffect(() => {
    const fetchData = async () => {
      dispatch({ type: 'request' });

      if (cache.current[url] && cached) {
        dispatch({ type: 'success', payload: cache.current[url] });
      } else {
        try {
          const res = await fetch(url, options);
          const json = await res.json();
          cache.current[url] = json;

          if (cancelRequest.current) return;

          if (res.status !== 200) {
            dispatch({ type: 'failure', payload: json.error });
          } else {
            dispatch({ type: 'success', payload: json });
          }
        } catch (error) {
          if (cancelRequest.current) return;

          dispatch({ type: 'failure', payload: error.message });
        }
      }
    };

    fetchData();

    return () => {
      cancelRequest.current = true;
    };
  }, [url, options, cached]);

  return state;
}

export default useFetch;

The second hook is a wrapper which provides some fetch options like a header containing a cookie to the useFetch hook. This way I wont have to write the same code over and over to do some requests against my API. I've called it useAPICall

export const useAPICall = <T>(url: string, body?: any, method = 'GET') => {
  return useFetch<T>(
    apiEndpoint + url,
    {
      method,
      body,
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
        'x-access-token': getCookie('token'),
      },
    },
    false
  );
};

getCookie returns the cookie called token from the document.

This is how I would use the useAPICall hook:

const { status, data, error } = useAPICall<Request>('/example/route');

My problem is that whenever I want to use the useAPICall or the useFetch hook, while providing an options object as argument, it causes infinite rerenders and a maximum update depth exceeded error.

I figured out the problem lies within the options dependency of useEffect in my useFetch hook, when I remove it from the dependency array the fetch request works nicely.

The only problem being that my linter tells me to include options in the dependency array because useEffect relies on it.

I'm now wondering how I can do it correctly.


Solution

  • The problem is passing an object literal as options will cause the dependencies of your useEffect() to change every time the hook re-renders, meaning that, as you experienced, you'll be fetching repeatedly because the effect will run again with the new options reference every time the previous fetch has just completed.

    In order to break the cycle, you'll need to either

    • memoize the options you pass in from useAPICall()
    • store your options in a useRef() within useFetch()

    Additionally, since you want the fetch to be sensitive to options changing, if you decide on the second approach, you'll need to manually compare the new reference to the previous one each time the useFetch() hook renders, and only use the new reference if the value has changed.

    Here's how you can implement the first approach in useAPICall(), leaving useFetch() alone:

    export const useAPICall = <T>(url: string, body?: any, method = 'GET') => {
      const options = {
        method,
        body,
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json',
          'x-access-token': getCookie('token'),
        },
      };
      const ref = useRef(options);
    
      if (
        ref.current.method !== options.method
        || ref.current.body !== options.body
        || ref.current.headers['x-access-token'] !== options.headers['x-access-token']
      ) {
        ref.current = options;
      }
    
      return useFetch<T>(
        apiEndpoint + url,
        ref.current,
        false
      );
    };
    

    Below is how you can implement most of the second approach, leaving useAPICall() alone, though I've left a comment where you'll need to flesh out the specifics of it:

    interface State<T> {
      status: 'idle' | 'pending' | 'error' | 'success';
      data?: T;
      error?: string;
    }
    
    interface Cache<T> {
      [url: string]: T;
    }
    
    type Action<T> =
      | { type: 'request' }
      | { type: 'success'; payload: T }
      | { type: 'failure'; payload: string };
    
    const initialState: State<any> = {
      status: 'idle',
      data: undefined,
      error: undefined,
    };
    
    function fetchReducer<T>(state: State<T>, action: Action<T>): State<T> {
      switch (action.type) {
        case 'request':
          return { ...initialState, status: 'pending' };
        case 'success':
          return { ...initialState, status: 'success', data: action.payload };
        case 'failure':
          return { ...initialState, status: 'error', error: action.payload };
        default:
          return state;
      }
    };
    
    // if (prev == next) { return prev; } else { return next; }
    function headersReducer(prev?: HeadersInit, next?: HeadersInit) {
      if (prev === next) {
        return prev;
      }
    
      if (!prev || !next) {
        return next;
      }
    
      if (
        // headers are equal
      ) {
        return prev;
      }
    
      return next;
    }
    
    // if (prev == next) { return prev; } else { return next; }
    function optionsReducer(prev?: RequestInit, next?: RequestInit) {
      if (prev === next) {
        return prev;
      }
    
      if (!prev || !next) {
        return next;
      }
    
      if (
        prev.body === next.body
        && prev.cache === next.cache
        && prev.credentials === next.credentials
        && prev.headers === headersReducer(prev.headers, next.headers)
        && prev.integrity === next.integrity
        && prev.keepalive === next.keepalive
        && prev.method === next.method
        && prev.mode === next.mode
        && prev.redirect === next.redirect
        && prev.referrer === next.referrer
        && prev.referrerPolicy === next.referrerPolicy
        && prev.signal === next.signal
        && prev.window === next.window
      ) {
        return prev;
      }
    
      return next;
    }
    
    export function useFetch<T = unknown>(
      url: string,
      options?: RequestInit,
      cached = false
    ): State<T> {
      const cache = useRef<Cache<T>>({});
      const optionsRef = useRef<RequestInit>();
      const [state, dispatch] = useReducer(fetchReducer, initialState);
    
      optionsRef.current = optionsReducer(optionsRef.current, options);
      const currentOptions = optionsRef.current;
    
      useEffect(() => {
        let cancelRequest = false;
    
        const fetchData = async () => {
          dispatch({ type: 'request' });
    
          if (cache.current[url] && cached) {
            dispatch({ type: 'success', payload: cache.current[url] });
          } else {
            try {
              const res = await fetch(url, currentOptions);
              const json = await res.json();
              cache.current[url] = json;
    
              if (cancelRequest) return;
    
              if (res.status !== 200) {
                dispatch({ type: 'failure', payload: json.error });
              } else {
                dispatch({ type: 'success', payload: json });
              }
            } catch (error) {
              if (cancelRequest) return;
    
              dispatch({ type: 'failure', payload: error.message });
            }
          }
        };
    
        fetchData();
    
        return () => {
          cancelRequest = true;
        };
      }, [url, currentOptions, cached]);
    
      return state;
    }
    
    export default useFetch;