@mrzr/api-client

Cancellation

Cancel in-flight requests when the user leaves a page, closes a modal or types the next search keystroke. Cancel by key, group, URL pattern or scope.

Cancellation is off by default. Turn it on once:

const api = createClient({ baseUrl, cancel: true });   // GET requests become cancelable

Only GET is covered, because cancelling a read is always safe. A write may already have reached the server, so opt writes in deliberately:

createClient({ cancel: { methods: "all" } });             // every method
createClient({ cancel: { methods: ["GET", "DELETE"] } }); // your pick
await api.post("/draft", body, { cancelable: true });     // just this one
await api.get("/session", { cancelable: false });         // opt a read out

Cancel what the user left behind

Start three 4-second requests, then cancel one of them or the whole page. A canceled request resolves with canceled: true instead of throwing.

  • GET /usersnot sent
  • GET /ordersnot sent
  • GET /reportsnot sent
api.get("/users", { cancelKey: "users", cancelGroup: "page" });
api.cancel("users");  // one request
api.cancel("page");   // everything the page started

Three ways to cancel

By key or group. Name a request, then cancel it by name:

api.get("/search", { cancelKey: "search", params: { q } });
api.cancel("search");

api.get("/cart", { cancelGroup: "checkout" });              // a request can have several groups
api.get("/shipping", { cancelGroup: ["checkout", "page"] });
api.cancel("checkout");                                     // stops both

A group only tags a request. A tagged write still needs cancelable: true.

By URL. Cancel everything under a path, or everything in flight:

api.cancel("/api/products");   // /api/products, /api/products/12, …
api.cancel();                  // all tracked requests, e.g. on route change

By scope. Group what a modal or page starts, and cancel it on close:

const scope = api.cancelScope("product-modal");
await scope.get("/products/12");
scope.cancel();   // on close

A scope has get, post, put, patch, delete, cancel() and pending(). Its reads are cancelable even if the client never set cancel: creating the scope is the opt-in. Writes still need cancelable: true.

URL patterns

Patterns match whole path segments, so products never matches products-archive. Query strings are ignored.

PatternMatchesDoesn't match
/api/products/api/products, /api/products/12, /api/products/12/reviews/api/products-archive
/api/products$/api/products only/api/products/12
/users/:id or /users/*/users/7, /users/7/posts/users
/users/:id$/users/7/users/7/posts
/api/**/images/api/images, /api/a/b/images/other/images

Every selector

api.cancel();                                         // everything in flight
api.cancel("/api/products");                          // a string: a key, then a group, then a URL pattern
api.cancel(/\/products\/\d+$/);                       // a regex, tested on the URL and path
api.cancel({ url: "/users/:id", method: "GET" });     // all given fields must match: url, method, key, group
api.cancel((r) => Date.now() - r.startedAt > 10_000); // your own rule
api.cancel("/api/products", "left the page");         // with a reason

cancel() returns how many requests it stopped. With nothing in flight it returns 0, so it's always safe to call.

pending() takes the same selectors and lists what's in flight:

if (api.pending("/api/products").length) showSpinner();

api.pending().map((r) => `${r.method} ${r.path}`);   // what patterns match against
// each entry: { id, method, url, path, key?, groups, startedAt }

Stale search results

takeLatest cancels the previous request with the same key, so only the last keystroke's results arrive:

api.get("/search", { cancelKey: "search", takeLatest: true, params: { q } });

Without a cancelKey, the identity is the method plus the path. Turn it on for every request with cancel: { takeLatest: true }; it also quietly removes the duplicate requests React StrictMode makes in development.

What a canceled request returns

It resolves, it doesn't throw:

const res = await api.get("/users", { cancelKey: "users" });
if (res.canceled) return;   // the user moved on; not an error
// res: { statusCode: 0, status: false, canceled: true, cancelReason: "left the page", … }

This is deliberate: if a cancellation threw, TanStack Query would treat it as a failure and retry the request you just canceled, and an async useEffect would log an unhandled rejection. onError never fires for a cancellation either.

If you'd rather it throw, set cancel: { throwOnCancel: true }, or throwOnCancel: true on one request. It's independent of throwError:

try {
  await api.get("/report", { throwOnCancel: true });
} catch (e) {
  if (e instanceof ApiError && e.canceled) return;
  throw e;
}

A timeout is not a cancellation: it's 408 and throws as usual. Your own AbortSignal behaves exactly like cancel(), so TanStack Query and SWR can cancel through theirs: api.get(url, { signal }).

login, logout and restoreSession are never canceled, even with methods: "all", so a route-change api.cancel() can't break signing in.

In worker mode, cancelling really stops the fetch inside the worker. It isn't just ignoring the answer.

In your framework

Cancel on unmount (React). Create the scope inside the effect, so a remount doesn't cancel the new request:

useEffect(() => {
  const scope = api.cancelScope("user-list");
  scope.get<User[]>("/users").then((res) => {
    if (!res.canceled) setUsers(res.data ?? []);
  });
  return () => scope.cancel();
}, []);

Cancel on navigation (Next.js App Router).

"use client";
import { usePathname } from "next/navigation";

export function CancelOnNavigate() {
  const pathname = usePathname();
  useEffect(() => () => { api.cancel(); }, [pathname]);
  return null;
}

Put <CancelOnNavigate /> in the root layout. Pass a pattern to cancel if some requests should survive navigation.

Vue. onUnmounted(() => scope.cancel()), or router.beforeEach(() => { api.cancel(); }) for every navigation.

On this page