swr icon indicating copy to clipboard operation
swr copied to clipboard

Cancel requests

Open tiagofernandez opened this issue 5 years ago • 15 comments

There are often times in a web application where you need to send a request for the latest user interaction. We need a way to prevent subsequent (async) logic from running for all but the most recent request – something like Axios' cancel token or fetch()'s AbortController.

tiagofernandez avatar Nov 16 '19 08:11 tiagofernandez

The hardest part about this is that every library has its own API. You can pass a controller to the fetch function but there’s no general way to do it inside SWR. And another question is when to cancel it (when unmounting a component?).

shuding avatar Nov 16 '19 12:11 shuding

I think it’s way easier to ignore the result than cancel it with the current state of fetching libraries

sergiodxa avatar Nov 17 '19 15:11 sergiodxa

For now a custom hook can (partially) solve it:

function useCancelableSWR (key, opts) {
  const controller = new AbortController()
  return [useSWR(key, url => fetch(url, { signal: controller.signal }), opts), controller]
}

// to use it:
const [{ data }, controller] = useCancelableSWR('/api')
// ...
controller.abort()

(also mentioned in #159)

shuding avatar Dec 01 '19 04:12 shuding

You probably don't want to memoize the AbortController.

You can only abort a controller instance once, so a new request requires a new controller.

AjaxSolutions avatar Jan 31 '20 00:01 AjaxSolutions

Another thing to note is that useMemo is a performance optimization with no hard guarantee that it won't be recalculated. React can choose to recalculate memoized values (https://reactjs.org/docs/hooks-reference.html#usememo).

strothj avatar Jan 31 '20 18:01 strothj

@strothj The issue is not whether the AbortController instance is properly cached or not. The abort controller should not be cached at all.

AjaxSolutions avatar Feb 01 '20 15:02 AjaxSolutions

I did this with axios

function useCancellableSWR(key, swrOptions) {
  const source = axios.CancelToken.source();

    return [
	useSWR(key, (url) => axios.get(url, { cancelToken: source.token }).then(res => res.data), {
		...swrOptions,
	}),
	source,
    ];
}

// usage:
const [{ data }, cancelFn] = useCancellableSWR('/endpoint');

cancelFn.cancel()

ekrresa avatar Apr 04 '20 08:04 ekrresa

Production-ready solution

Note: An AbortController is created with each request

export function useSWRAbort<Data = any, Error = any>(
  key: keyInterface,
  fn?: fetcherFn<Data>,
  options?: ConfigInterface<Data, Error>
): responseInterface<Data, Error> & {
  abort: () => void
} {
  const aborter = useRef<AbortController>()
  const abort = () => aborter.current?.abort()

  const res = useSWR<Data, Error>(key, (...args) => {
    aborter.current = new AbortController()
    return fn?.(aborter.current.signal, ...args)
  }, options)

  return { ...res, abort }
}

Example

Your fetcher gets an extra param signal (AbortSignal) before everything else

You can then pass it to your actual fetcher, for example fetch

const { data, error, abort } = useSWRAbort<T>(url, (signal, url) => {
  return fetch(url, { signal }).then(res => res.json())
})

return <button onClick={abort}>
  ...
</button>

Or use it to invalidate data afterwards

const { data, error, abort } = useSWRAbort<T>(url, async (signal, url) => {
  const res = await fetch(url)
  if (signal.aborted) throw new Error("Aborted")
  return await res.json()
})

Test with a timeout

const { data, error, abort } = useSWRAbort<T>(url, async (signal, url) => {
  await new Promise(ok => setTimeout(ok, 5000))
  console.log("aborted?", signal.aborted)
  const res = await fetch(url, { signal })
  return await res.json()
})

hazae41 avatar Feb 26 '21 17:02 hazae41

Does config.isPaused() work for your cases?

useSWR(key, fetcher, {
  isPaused() {
    return /* condition for dropped requests */
  }
})

huozhi avatar Mar 11 '21 03:03 huozhi

could this also be solved by a middleware @shuding?

pke avatar Feb 26 '22 22:02 pke

I was able to solve this when using Axios and the middleware like @pke suggests:

https://swr.vercel.app/docs/middleware

  let cancelToken;

  const cancelMiddleware = (useSWRNext) => {
    return (key, fetcher, config) => {
      const extendedFetcher = (...args) => {
        if (typeof cancelToken !== "undefined") {
          cancelToken.cancel("Operation cancelled due to new request.");
        }

        cancelToken = axios.CancelToken.source();
        return fetcher(...args);
      };

      return useSWRNext(key, extendedFetcher, config);
    };
  };

ezeikel avatar Apr 06 '22 09:04 ezeikel

Not sure I understand how this works 😉 The cancel token is called immediately?

edit: Ok I get it now. However, shouldn't the cancelToken be defined inside the middleware? Otherwise you can always only have one request going and all others will be cancelled?

pke avatar Apr 06 '22 10:04 pke

One more solution as a middleware that automatically aborts the request as soon as the key is changed or component is unmounted.

const abortableMiddleware: Middleware = (useSWRNext) => {
  return (key, fetcher, config) => {
    // for each key generate new AbortController
    // additionally the key might be serialised using unstable_serialize, depending on your usecases
    const abortController = useMemo(() => new AbortController(), [key]);

    // as soon as abortController is changed or component is unmounted, call abort
    useEffect(() => () => abortController.abort(), [abortController]);

    // pass signal to your fetcher in way you prefer
    const fetcherExtended: typeof fetcher = (url, params) =>
      fetcher(url, { ...params, signal: abortController.signal });

    return useSWRNext(key, fetcherExtended, config);
  };
};

It also possible to play with this in my sandbox.

garipov avatar May 18 '22 11:05 garipov

I have a new proposal here: #1933, feedback welcome!

shuding avatar May 19 '22 13:05 shuding

One more solution as a middleware that automatically aborts the request as soon as the key is changed or component is unmounted.

const abortableMiddleware: Middleware = (useSWRNext) => {
  return (key, fetcher, config) => {
    // for each key generate new AbortController
    // additionally the key might be serialised using unstable_serialize, depending on your usecases
    const abortController = useMemo(() => new AbortController(), [key]);

    // as soon as abortController is changed or component is unmounted, call abort
    useEffect(() => () => abortController.abort(), [abortController]);

    // pass signal to your fetcher in way you prefer
    const fetcherExtended: typeof fetcher = (url, params) =>
      fetcher(url, { ...params, signal: abortController.signal });

    return useSWRNext(key, fetcherExtended, config);
  };
};

It also possible to play with this in my sandbox.

import { useEffect, useRef } from 'react';
import { Middleware, SWRHook, unstable_serialize } from 'swr';

export type CancellablePromise<T> = Promise<T> & {
  cancel: (str?: string) => void;
};

export const cancelMiddleware: Middleware =
  (useSWRNext: SWRHook) => (key, fetcher, config) => {
    const cancelRef = useRef<() => void>();
    const keyString = unstable_serialize(key);
    const extendsFetcher = fetcher
      ? (...rest: any) => {
          const request = fetcher(...rest) as CancellablePromise<any>;
          cancelRef.current = request.cancel;
          return request;
        }
      : fetcher;
    const swr = useSWRNext(key, extendsFetcher, config);

    useEffect(() => {
      return () => {
        cancelRef.current?.();
      };
    }, [keyString]);
    return swr;
  };

The key needs to be serialized

MyCupOfTeaOo avatar Jul 22 '22 07:07 MyCupOfTeaOo