Custom React Hooks: A useAxios Hook and the Bugs Its Tests Found

By Sergey Nosov

16 October 2026

A React component that loads its own data repeats the same lines: state for the data, a loading flag, an error, and an effect that starts the request. A cleanup that cancels the request appears when someone remembers it. A custom hook writes those lines once and gives them a name. The component says what it wants; the hook decides how.

In March 2025 I built one as a demonstration: useAxios, which wraps the Axios HTTP client with retries, backoff, cancellation, an optional cache, and three loading flags. It worked one call at a time. Then I tested two quick clicks, a new request while a retry waits, and a page left during a request. The tests turned up loading flags that lie, stale answers that win, and requests that outlive their component. React’s recommended lint rules report none of it.

Most of these bugs break one rule. A request that a hook starts belongs to the effect run that started it, and once that run is over, the request must not touch state. This article covers when a custom hook is worth writing and how to name and shape one. It then turns to what the tests found, a corrected version, and when a library should replace your hook.

What a Custom Hook Is

A custom hook is a JavaScript function that calls other hooks. React’s documentation on reusing logic with custom hooks sets the naming rule: “Hook names must start with use followed by a capital letter.” The prefix is a promise to the reader. A call to useOnlineStatus() may hide state and effects; a call to getColor() cannot.

The same page states the converse: “Functions that don't call Hooks don't need to be Hooks.” A helper that formats a date or sorts a list is a plain function, callable anywhere, even inside a condition.

Two more rules follow because a hook runs as part of a component. First, every call is separate, as React puts it: “Custom Hooks let you share stateful logic but not state itself. Each call to a Hook is completely independent from every other call to the same Hook.”

Second, a hook runs on every render, so the Rules of Hooks apply to it as they do to components. Call hooks at the top level only, never inside conditions, loops, event handlers, or try blocks. The eslint-plugin-react-hooks package checks both the names and the rules.

When a Hook Is Worth Writing

Duplication alone is a weak reason. React’s documentation agrees that “some duplication is fine” and calls a hook that wraps a single useState call “probably unnecessary.” The Rule of Three, which has its own entry in my Software Development Principles series, gives the usual threshold. Tolerate two copies, and extract on the third, once the copies have shown which parts vary.

An effect is a stronger reason. The same page advises, “whenever you write an Effect, consider whether it would be clearer to also wrap it in a custom Hook.” An effect is where a component steps outside React to talk to a network, a timer, or a browser API.

Wrapped in a hook named for its purpose, an effect tells the reader what the component needs. The hook also keeps others from adding stray dependencies to the effect, and it leaves one place to change when React offers something better. React shows that last benefit with useOnlineStatus, which moves from an effect to useSyncExternalStore without changing any component that calls it.

Some hooks are not worth writing at all. The page warns against “lifecycle” hooks such as useMount(fn) and useEffectOnce(fn), which wrap useEffect itself. Its sample useMount ignores changes to the values it uses, and, the page notes, “the linter only checks direct useEffect calls. It won't know about your Hook.” That is the default; the plugin’s additionalEffectHooks setting can register custom hooks that take a dependency array, which useMount does not. Name the purpose instead.

Naming and Shaping a Hook

A good name says what the caller gets. The page’s examples include useData(url) and useChatRoom(options), and a technical name such as useMediaQuery(query) suits a hook that wraps one external system. If no clear name comes, the page suggests, the effect may be “too coupled to the rest of your component's logic, and is not yet ready to be extracted.”

“A good custom Hook makes the calling code more declarative by constraining what it does,” the page says. When a custom hook’s API “doesn't constrain the use cases and is very abstract,” it adds, “in the long run it's likely to introduce more problems than it solves.” The name useAxios points at the library it wraps, which is acceptable for an infrastructure hook. Components read better when they call a domain hook built on it, such as useForecast(city).

Reading data and changing data are different jobs. React’s page on when you might not need an effect draws the line: “If this logic is caused by a particular interaction, keep it in the event handler. If it's caused by the user seeing the component on the screen, keep it in the Effect.” Loading data because a component is on screen belongs in an effect, or in a library built on one. A POST that places an order belongs in a click handler.

Shape the inputs so that React can compare them. It compares an effect’s dependencies with Object.is, so an object literal written inside a component is new on every render. “Object and function dependencies can make your Effect re-synchronize more often than you need,” React’s guide to removing effect dependencies warns, and it suggests “extracting primitive values out of them.” A URL string compares by value; a configuration object does not.

Shape the outputs for the caller. An object result lets a component take only the fields it needs, by name. Functions in the result should keep their identity across renders, so that a caller can list them as effect dependencies. React Compiler 1.0, released on 7 October 2025, memoizes automatically. Its announcement still keeps useCallback for this case: “A common use-case for this is if a memoized value is used as an effect dependency.”

Code that needs no React belongs in plain functions, which tests can call without rendering anything. A hook that calls a caller’s callback from its effect, such as an onSuccess handler after a request, can wrap it with useEffectEvent. That hook, stable since React 19.2, released on 1 October 2025, keeps the callback out of the effect’s dependencies. The custom-hooks page sums it up: “Wrap event handlers received by custom Hooks into Effect Events.” The useEffectEvent reference allows calling them only from inside effects.

The useAxios Hook

The demonstration has two parts. A .NET web API waits 500 milliseconds, then returns a weather forecast with a 30 percent chance; otherwise it answers 503 Service Unavailable, on purpose. A React page shows the forecast. Its first version fetched inside an effect, with no loading state, no error handling, and no cleanup.

The hook replaced that effect with one call, configured for seven attempts, a first delay of 50 milliseconds, and no automatic fetch; a new Fetch Weather button calls fetchData. The code is in my react-axios-custom-hook repository on GitHub. Its options and state show what it set out to do:

function useAxios<T>(
  config: AxiosRequestConfig,
  {
    retries = 5,
    delay = 1000,
    autoFetch = true,
    params: initialParams = {},
    cacheEnabled = false,
  }: UseAxiosOptions = {}
): UseAxiosResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState<boolean>(false);
  const [isInitialLoading, setIsInitialLoading] = useState<boolean>(true);
  const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);
  const [params, setParams] = useState<Record<string, unknown>>(initialParams);

Much of it is good practice. The options arrive in one object with defaults, and the result is typed. The cache helpers, getCachedData and saveToCache, are plain functions without the use prefix, as the naming rule asks. Cancellation uses AbortController, which Axios has supported since version 0.22.0, rather than the CancelToken API that its README calls deprecated. Retries are limited to four server errors—500, 502, 503, and 504—and the delay doubles each time.

The core of the hook is fetchData, shown here reindented, with a few lines cut and long lines rewrapped:

const fetchData = useCallback(
  async (newParams?: Record<string, unknown>) => {
    const combinedParams = {...params, ...newParams};
    setParams(combinedParams);
    // ...
    if (cacheEnabled) {
      cacheKey = JSON.stringify({...config, params: combinedParams});
      const cachedData = getCachedData(cacheKey) as T | null;
      if (cachedData) {
        console.log("Using cached data");
        setData(cachedData);
        setIsInitialLoading(false);
        return;
      }
    }

    if (abortController.current) {
      abortController.current.abort();
    }

    abortController.current = new AbortController();

    setData(null);
    setIsInitialLoading(!initialFetchTriggered.current);
    setIsRefreshing(initialFetchTriggered.current);
    setLoading(true);
    setError(null);

    const attemptRequest = async (attempt = 1): Promise<void> => {
      try {
        const response = await axios({
          ...config,
          params: combinedParams,
          signal: abortController.current?.signal,
        });
        // ...
        setData(response.data);
      } catch (err) {
        if (axios.isCancel(err)) {
          console.warn("Request canceled");
        } else if (
          attempt < retries &&
          axios.isAxiosError(err) &&
          [500, 502, 503, 504].includes(err.response?.status || 0)
        ) {
          const nextDelay = delay * 2 ** (attempt - 1);
          // ...
          await new Promise((res) => setTimeout(res, nextDelay));
          await attemptRequest(attempt + 1);
        } else {
          setError(err instanceof Error
            ? err.message
            : "An unknown error occurred");
        }
      } finally {
        setIsInitialLoading(false);
        setIsRefreshing(false);
        setLoading(false);
      }
    };

    await attemptRequest();
  },
  [config, params, retries, delay, cacheEnabled]
);

What the Tests Found

I ran the original hook, unchanged, under Vitest with jsdom, React 19.3.1, and Axios 1.20.0. A small local server played the API: it delays, fails, or never answers on request, and it logs every request, including the ones the client gave up on.

The Ref That Hides a Loop

The hook fetches on mount through this effect:

useEffect(() => {
  if (autoFetch && !initialFetchTriggered.current) {
    initialFetchTriggered.current = true;
    fetchData().then();
  }
}, [fetchData, autoFetch]);

The ref makes sure the effect fetches only once. React’s guide to synchronizing with effects calls such a ref, usually meant to silence the second run that Strict Mode adds in development, “a common pitfall.” “To fix the bug, it is not enough to just make the Effect run once,” the guide says. The right question, it adds, is not how to run an effect once but “how to fix my Effect so that it works after remounting.”

Here the ref does more than silence Strict Mode: fetchData depends on config and params, and neither holds still. The demo page passes a new configuration object on every render, and every call to fetchData stores a new params object. So fetchData changes after every render and after every call. With autoFetch on, its default, only the ref stands between the effect and a loop. (The demo page turns autoFetch off, so it would not loop.)

In a test that removes just the ref check, a component with autoFetch on sent about a thousand requests in its first second. Each new call aborted the request before it, so almost none finished, and React logged “Maximum update depth exceeded.” With the ref in place, the loop is gone, and so is the hook’s ability to follow its inputs. When the URL changes, the hook never fetches the new one on its own.

Without the ref, React Compiler does not end the loop. Compiled with version 1.0, the demo page creates its configuration object once, but each call to fetchData still stores a new params object. In my test, a fixed configuration still produced about a thousand requests a second. The compiler also skips the hook itself, reporting that it does not handle a try statement with a finally clause, even inside a callback. Only a lint rule outside the recommended preset reports that.

A Canceled Request Still Changes State

Click Fetch Weather twice in a row. The second call aborts the first request and starts a new one. Axios rejects the first request with a cancellation error, and the first call’s catch logs it, but its finally block still sets loading to false. My test read the state 60 milliseconds after the second call, with the second request still running, and found loading: false and data: null. The demo page drops its loading message and shows its “No forecasts” row until the second answer arrives.

The guide anticipates this: a fetching effect’s cleanup, it says, should “abort the fetch or ignore its result.” Aborting alone is not enough, the guide adds: “More asynchronous steps could be chained after the fetch, so using an explicit flag like ignore is the most reliable way to fix this type of problem.” In useAxios, the steps chained after the fetch are the finally block and the retries.

A Retry Outlives Its Call

The retries have the same flaw in a worse place: attemptRequest reads abortController.current each time it sends a request, not once when the call begins. Suppose a request for city A fails with a 503 and waits to retry, and meanwhile the user picks city B. The new call aborts the old controller, but the old call is asleep in its backoff, not waiting on a request, so nothing stops it.

When it wakes, it reads the new call’s controller and sends its retry with that signal. My test logged three requests: A, answered with a 503; B, answered with data; and A again, answered with data. The page ended on city A’s forecast, the answer to a question the user had stopped asking.

The cache has a third version of the problem. A cache hit returns before the line that aborts the previous request. If the user asks for A, which is not cached, and then for B, which is, the page shows B at once and then A when A’s answer arrives.

Nothing Ends When the Component Does

The hook has no cleanup at all. When the component unmounts, its request keeps going, and so do its retries. In my test, a component removed 20 milliseconds after it mounted was followed by two more requests, and the server answered all three. React 18 removed the warning about state updates on an unmounted component. Its upgrade guide says people “primarily run into it in scenarios where setting state is fine, and workarounds make the code worse.” React says nothing about these requests now.

Some requests never end on their own. Axios’s default timeout is 0, which means none, and its README advises, “Set a timeout in production. Without one, a stalled request can hang indefinitely.” Against a server that never answered, my test still read loading: true after a second and a half.

Promises the API Does Not Keep

The option retries: 5 promises five retries, and the comment above the hook says “Default: five retries.” But attempt < retries, with attempt starting at 1, allows five attempts, which is four retries. The Meaningful Naming entry in Software Development Principles asks that names not “promise behavior they do not deliver.” An option name is a promise the caller cannot check without reading the code.

Two of the hook’s six state variables, isInitialLoading and isRefreshing, split loading by whether a fetch has happened before. React states the rule plainly: “When something can be calculated from the existing props or state, don't put it in state.”

Stored separately, these two go wrong in both modes. The effect sets its run-once ref just before the first automatic fetch, so that fetch reports isRefreshing: true and isInitialLoading: false. A click never sets the ref, so with autoFetch: false, every fetch counts as an initial load and none as a refresh. The error, meanwhile, is a string, so a caller must parse a message to tell a 404 from a 503.

A POST Sent on Mount, Then Retried

The hook sends whatever the configuration says. A configuration with method: "POST" goes out as soon as the component mounts, because autoFetch defaults to true. After a 503 it goes out again; my test logged two POSTs.

RFC 9110, the HTTP specification, is explicit: “A client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied.” Some failures arrive after the work is done. A gateway’s 504 means that it stopped waiting, not that the order was never placed. My article on idempotency in APIs shows how an idempotency key makes such a retry safe.

What the Linter Saw

None of this reaches a linter. The recommended preset of eslint-plugin-react-hooks 7.1.1, which includes React Compiler’s checks, reported nothing on the hook or the demo page. A control file with a setState call directly in an effect and a missing dependency drew an error and a warning, so the rules were running.

The hook’s dependency lists are complete, and its state updates sit inside fetchData, not in the effect’s body. Lint rules check the shape of the code. These bugs live in the order of events, where a test that produces those orders can show them.

A Version That Holds Up

The corrected hook keeps the idea and changes who owns each request. Every request starts inside an effect run, and that run’s cleanup aborts it. Before the code touches state, it checks whether its run was canceled. The effect depends on a URL string, not on a configuration object. Whether the page is loading, and which answer it may show, are computed, not stored. And the hook only reads: it sends GET requests, and writes go through plain functions called from event handlers.

The retry loop moved out of the hook into a plain function:

import axios from "axios";

const RETRY_ON = new Set([500, 502, 503, 504]);

export type RetryOptions = {
  retries: number;
  delay: number;
  timeout: number;
  signal: AbortSignal;
};

// A plain function, not a Hook:
// it calls no Hooks, so it can
// run anywhere, tests included.
export async function getWithRetry<T>(
  url: string,
  { retries, delay, timeout, signal }: RetryOptions,
): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      const response = await axios.get<T>(url, { signal, timeout });
      return response.data;
    } catch (err) {
      const status = axios.isAxiosError(err)
        ? err.response?.status
        : undefined;
      const retryable = status !== undefined && RETRY_ON.has(status);
      if (!retryable || attempt >= retries) throw err;
      // Full jitter: a random
      // wait below a ceiling
      // that doubles.
      const ceiling = delay * 2 ** attempt;
      await sleep(Math.random() * ceiling, signal);
    }
  }
}

// Waits ms, or rejects as soon
// as the signal aborts.
function sleep(ms: number, signal: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    signal.throwIfAborted();
    const onAbort = () => {
      clearTimeout(timer);
      reject(signal.reason);
    };
    const timer = setTimeout(() => {
      signal.removeEventListener(
        "abort",
        onAbort,
      );
      resolve();
    }, ms);
    signal.addEventListener("abort", onAbort, {
      once: true,
    });
  });
}

The retries option now counts retries after the first attempt. Each wait is random, between zero and a ceiling that doubles, which spreads out the retries of clients that failed together. My article on the Backpressure-First Principle lists the rules for client retries, and this sample skips one: wait at least as long as a 503’s Retry-After header asks. The wait listens to the signal, so a canceled call stops at once instead of sleeping through its backoff, and each attempt gets a timeout, 10 seconds by default.

The hook keeps a run counter and one result. Calling fetchData no longer sends a request; it adds one to the counter. The effect depends on a key built from the URL, the run count, and the options. So the first mount, a click, a new URL, and a changed option all take the same path:

import { useCallback, useEffect, useState } from "react";
import { getWithRetry } from "./getWithRetry";

type Options = {
  retries?: number;
  delay?: number;
  timeout?: number;
  autoFetch?: boolean;
};

type Result<T> = {
  url: string;
  key: string;
  data: T | null;
  error: Error | null;
};

export function useAxios<T>(
  url: string,
  {
    retries = 3,
    delay = 1000,
    timeout = 10_000,
    autoFetch = true,
  }: Options = {},
) {
  // fetchData asks for a new run.
  const [run, setRun] = useState(0);
  const [result, setResult] = useState<Result<T> | null>(null);
  const active = autoFetch || run > 0;
  // Everything that starts a new
  // request, as one value.
  const key = JSON.stringify([url, run, retries, delay, timeout]);

  useEffect(() => {
    if (!active) return;
    const controller = new AbortController();
    const { signal } = controller;
    getWithRetry<T>(url, { retries, delay, timeout, signal })
      .then((data) => {
        if (signal.aborted) return;
        setResult({ url, key, data, error: null });
      })
      .catch((error: unknown) => {
        if (signal.aborted) return;
        const err =
          error instanceof Error ? error : new Error(String(error));
        // Keep the last good data
        // for the same URL.
        setResult((last) => ({
          url,
          key,
          data: last?.url === url ? last.data : null,
          error: err,
        }));
      });
    // A new key or an unmount
    // cancels this request.
    return () => controller.abort();
  }, [active, key, url, retries, delay, timeout]);

  const fetchData = useCallback(() => setRun((n) => n + 1), []);

  // Data for this URL; loading
  // and error for this request.
  const current = result?.url === url ? result : null;
  const settled = current !== null && current.key === key;
  return {
    data: current?.data ?? null,
    error: settled ? current.error : null,
    loading: active && !settled,
    fetchData,
  };
}

When the key changes or the component unmounts, React runs the cleanup of the previous effect run, which aborts its request and any retry waiting behind it. The signal.aborted checks play the part of the guide’s ignore flag for any step that finishes after the abort.

React’s advice to keep an interaction’s logic in its event handler fits a POST. A refetch is a GET, safe to repeat, and the click only says which answer the screen should show. React’s own search example works the same way: its Next button sets page, and the effect fetches that page. With autoFetch: false, the hook waits for the first fetchData call; after that, a new URL fetches on its own.

The hook tags its one result with the URL and the key that produced it. The variable current holds that result if it belongs to this URL, and settled is true when it also belongs to this key. The returned data comes from current, so an answer for a different URL is never shown, and switching cities clears the old forecast at once. A refetch of the same URL keeps the last answer on screen.

The loading flag stays true until a result for this key arrives, and error shows only that result’s error. One gap remains. If the URL or an option changes and then changes back before an answer replaces the one held, the held result matches the key again. The page shows that earlier result, with no loading state, until the new one arrives. The data matches the URL; only the flag lags.

A failed refetch keeps the last good data next to the error, which SWR’s documentation also allows: “Note that data and error can exist at the same time.” A thrown Error passes through unchanged, so a caller can check axios.isAxiosError(error) and read the status.

Under the same scenarios, the corrected hook passes. Two quick calls keep loading true until the second answer, and the server logs the first request as abandoned. A new city during a backoff stops the old retries: the server saw one request for A, one for B, and nothing more. An unmount aborts the request in flight, and no retry follows.

With a 200-millisecond timeout, a request to a server that never answers produced a timeout error. The option retries: 5 now allows six attempts, and against a server that always fails, the hook sent six. Changing an option after a success shows loading again while the new request runs. Under Strict Mode in development, the effect runs twice, and in my test the first run’s request was canceled before it reached the server, which saw one request.

The page and a domain hook use it like this. Both URLs are illustrative: the demo reads its API address from a Vite setting, and its API takes no city. With retries: 6, the page keeps the demo’s seven attempts, for a server that fails 70 percent of requests on purpose:

import { useAxios } from "./useAxios";

type Forecast = { date: string; summary: string };

export function WeatherForecast() {
  const { data, loading, error, fetchData } = useAxios<Forecast[]>(
    "/api/weatherforecast",
    { retries: 6, delay: 50, autoFetch: false },
  );
  return (
    <>
      <button onClick={fetchData}>Fetch Weather</button>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      <ul>
        {data?.map((f) => (
          <li key={f.date}>{f.date}: {f.summary}</li>
        ))}
      </ul>
    </>
  );
}

// A domain Hook says what the
// component wants, not how.
export function useForecast(city: string) {
  const query = new URLSearchParams({ city });
  const url = `/api/forecast?${query}`;
  return useAxios<Forecast[]>(url);
}

Testing a Hook’s Timing

Many hook tests check one call at a time; the bugs above need sequences. Two details make such tests dependable. First, replace getWithRetry with a fake that answers after a set delay or rejects when aborted, so that no server is needed. My other tests, against the local server, cover retries, timeouts, and unmounting.

Second, turn on Strict Mode with Testing Library’s reactStrictMode option. In my tests, a wrapper component that rendered <StrictMode> did not repeat effects on mount. React’s Strict Mode reference explains why: “if <StrictMode> is not enabled at the root of the app, it will not re-run Effects an extra time on initial mount.” The test below uses Vitest and Testing Library:

import { act, renderHook, waitFor } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { useAxios } from "./useAxios";

// A fake network: it answers with
// the URL after 50 ms, or rejects
// when its signal aborts.
const signals = vi.hoisted(() => [] as AbortSignal[]);
vi.mock("./getWithRetry", () => ({
  getWithRetry: (url: string, { signal }: { signal: AbortSignal }) => {
    signals.push(signal);
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => resolve(url), 50);
      signal.addEventListener("abort", () => {
        clearTimeout(timer);
        reject(signal.reason);
      });
    });
  },
}));

test("a refetch keeps the old data and cancels the one before", async () => {
  const url = "/api/forecast";
  const { result } = renderHook(() => useAxios<string>(url), {
    reactStrictMode: true,
  });
  const now = () => result.current;
  await act(async () => {});
  // Strict Mode ran the effect
  // twice and canceled the first
  // request; the second is still
  // pending.
  expect(signals.map((s) => s.aborted)).toEqual([true, false]);
  expect(now().loading).toBe(true);
  expect(now().error).toBeNull();
  await waitFor(() => expect(now().data).toBe(url));

  signals.length = 0;
  act(() => now().fetchData());
  act(() => now().fetchData());
  expect(now().loading).toBe(true);
  expect(now().data).toBe(url);
  await waitFor(() =>
    expect(now().loading).toBe(false),
  );
  expect(signals.map((s) => s.aborted)).toEqual([true, false]);
});

This test fails if the cleanup stops aborting or if a canceled run reports its error. It also fails if loading ignores the key, if a refetch hides the old data, or if fetchData does nothing. I checked each by breaking the hook on purpose. Two other breaks get past it. Because its fake never answers after an abort, it cannot catch a missing signal.aborted check on success. And because it changes no option, another of my tests covers the options in the key.

Testing Library’s documentation calls renderHook “mostly interesting for libraries publishing hooks” and recommends render with a small test component instead. A hook shared across an application is close to the first case.

When to Stop Writing Your Own

The corrected hook still has no cache, no deduplication, and no shared state: two components that ask for the same forecast send two requests. The guide to synchronizing with effects ranks the alternatives: “If you use a framework, use its built-in data fetching mechanism.” Otherwise, it says, “consider using or building a client-side cache,” such as TanStack Query, SWR, or React Router. If you build your own, the guide says to add “logic for deduplicating requests, caching responses, and avoiding network waterfalls.”

TanStack Query also settles the questions this article answered by hand. It passes every query function an AbortSignal. In the browser, it retries a failed query three times by default, with a delay that doubles from 1 second up to 30 seconds. It does not retry mutations unless asked, and its default delay has no jitter, so pass your own retryDelay if many clients can fail together.

By default, TanStack Query lets a query finish when it becomes unused, as when its last component unmounts. Its cache keeps an inactive query’s answer for 5 minutes, the default, in case a component asks again. A query function that passes the signal on to its request is canceled instead, except with the Suspense hooks. A hook without a cache has no use for that answer, which is why the corrected hook cancels.

SWR’s documentation shows five components that ask for the same key at almost the same moment and says that “only 1 network request will be made.” They call a useUser hook that wraps useSWR, the way this article wraps useAxios. Whatever you choose, keep the domain hook. A component that calls useForecast(city) does not change when useAxios gives way to TanStack Query.

It may not change for React’s own approach either. The custom-hooks page shows the use API reading a promise during render and says of data fetching, “We're still working out the details.” Custom hooks like useData, it adds, “will require fewer changes to migrate to the eventually recommended approach than if you write raw Effects in every component manually.”

Axios itself is optional. The browser’s fetch needs a few more lines for the same behavior: it does not reject on an error status such as 404, and AbortSignal.timeout() supplies the timeout it lacks. If you keep Axios, pin it with a lockfile. On 31 March 2026, two releases published from the lead maintainer’s compromised npm account pulled in a dependency that installed a remote access trojan. His post-mortem says they “were live for about 3 hours.”

A Checklist for Custom Hooks

And before writing the next one, check whether TanStack Query or SWR already does the job.

Further Reading