swjh
All posts

useSWR enhanced to prevent error message flashing on retry

   ⬪   5 min read
#react

When building data-driven UIs, you often want to fetch data silently in the background, retry on failure, but only show an error to the user after you’ve genuinely given up. This hook does exactly that.
The amazing SWR package has one flaw: when retry happens, the error state is briefly active and thus your error message might flash up. To prevent this, I created ths wrapper, as drop-in-replacement for useSWR:

import { useState } from "react";
import useSWR from "swr/immutable";

/**
 * Drop-in replacement for `useSWR` that suppresses the error state
 * until all retries have been exhausted, preventing error message flashing.
 *
 * @param apiPath - The API endpoint to fetch. Used as the SWR cache key.
 * @param shouldFetch - When `false`, fetching is skipped without unmounting
 *   (achieved by passing `undefined` as the SWR key).
 * @returns SWR state plus `errorAfterRetries`, which is only defined once
 *   all {@link MAX_RETRIES} attempts have failed.
 */
export const useSWRWIthErrorAfterRetries = (
  apiPath: string,
  shouldFetch?: boolean,
) => {
  /** Maximum number of retry attempts before surfacing the error to the UI. */
  const MAX_RETRIES = 5;
  /** Delay between retries in milliseconds, adjust to your needs */
  const RETRY_TIMEOUT = 10;

  /** Tracks how many retries have occurred so far. */
  const [retries, setRetries] = useState(0);

  const { data, error, isLoading, isValidating } = useSWR(
    shouldFetch ? apiPath : undefined,
    fetcher,
    {
      shouldRetryOnError: true,
      errorRetryCount: MAX_RETRIES,
      errorRetryInterval: RETRY_TIMEOUT, // in ms
      /**
       * Custom retry handler that increments the local retry counter and
       * stops retrying once `MAX_RETRIES` is reached.
       */
      onErrorRetry(_err, _key, config, revalidate, opts) {
        const currentRetry = opts.retryCount || 0;
        setRetries(currentRetry);
        if (currentRetry >= (config.errorRetryCount || 0)) {
          return; // Stop retrying
        }
        setTimeout(() => void revalidate(opts), RETRY_TIMEOUT);
      },
      /** Reset the retry counter whenever a request succeeds. */
      onSuccess() {
        setRetries(0);
      },
    },
  );
  if (error) {
    console.error(error);
  }
  /** Only expose the error to callers after all retries are exhausted. */
  const errorAfterRetries = retries >= MAX_RETRIES ? error : undefined;
  return { data, error, isLoading, isValidating, errorAfterRetries };
};

The Problem

SWR’s built-in errorRetryCount will retry a failed request, but error becomes truthy on the first failure, before any retries have happened. If you just render an error message on !!error, your UI flashes an error message even though SWR is still working in the background.

The Solution

useSWRWithErrorAfterRetries wraps useSWR and adds a single piece of derived state: errorAfterRetries. It uses a retries counter (tracked via useState) that increments inside onErrorRetry, and only surfaces the error once retries >= MAX_RETRIES (5 attempts).

const errorAfterRetries = retries >= MAX_RETRIES ? error : undefined;
return { data, error, isLoading, isValidating, errorAfterRetries };

Callers get both the raw error (for logging/internal use) and errorAfterRetries (safe to drive UI error states).

Usage

const { data, errorAfterRetries, isLoading } =
  useSWRWithErrorAfterRetries("/api/data");

if (isLoading) return <MySpinner />;
if (errorAfterRetries) return <MyErrorMessage />; // Will not flash anymore on retries
return <MyList stops={data} />;

The hook gives you clean, user-facing error handling without prematurely alarming users on the first hiccup.

Bonus: A strict Fetcher

The fetcher enforces strict success semantics beyond a plain HTTP 200:

import type { Fetcher } from "swr";
/**
 * Strict fetcher that treats non-OK responses, 204 No Content, and empty
 * array payloads as errors — ensuring they trigger SWR's retry logic.
 *
 * @param args - Arguments forwarded directly to the native `fetch` call.
 * @returns The parsed JSON response, guaranteed to be a non-empty array.
 * @throws {Error} On non-OK HTTP status, 204, or an empty/non-array result.
 */
export const fetcher: Fetcher<T> = async (...args) => {
  return fetch(...args).then((res) => {
    if (!res.ok) {
      throw new Error(`${res.status}: Fetching failed ${res}`);
    }
    if (res.status === 204) {
      throw new Error("Fetch returned empty result");
    }

    const data = res.json();

    // also throw error if result is an empty array
    return data.then((result) => {
      if (!Array.isArray(result) || result.length === 0) {
        throw new Error("Fetch returned empty array");
      }
      return result;
    });
  });
};

This means transient “empty” responses don’t silently succeed — they trigger retries too.