Networking, data fetching and offline support

Wrap fetch with timeouts and retries, cache and deduplicate with TanStack Query, detect connectivity, and queue mutations so a lost signal does not lose work.

A fetch wrapper worth having

export class HttpError extends Error {
  constructor(readonly status: number, message: string) {
    super(message);
    this.name = 'HttpError';
  }
}

export async function request<T>(
  path: string,
  init: RequestInit & { timeoutMs?: number; retries?: number } = {},
): Promise<T> {
  const { timeoutMs = 15000, retries = 2, ...rest } = init;

  for (let attempt = 0; ; attempt++) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const response = await fetch('https://api.example.com' + path, {
        ...rest,
        signal: controller.signal,
        headers: { Accept: 'application/json', ...(rest.headers ?? {}) },
      });
      if (!response.ok) throw new HttpError(response.status, 'Request failed');
      return (await response.json()) as T;
    } catch (error) {
      const retryable = error instanceof HttpError ? error.status >= 500 : true;
      if (attempt >= retries || !retryable) throw error;
      await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 300));
    } finally {
      clearTimeout(timer);
    }
  }
}
  • fetch has no timeout in React Native: without an AbortController a request on a dead network hangs until the platform gives up.
  • Retry only transport failures and 5xx responses. Retrying a 4xx multiplies load and never succeeds.
  • Back off exponentially with jitter; a fixed delay makes every client retry in lockstep.
  • Never retry a non-idempotent POST unless you send an idempotency key the server can deduplicate.

Caching and deduplication

import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      gcTime: 5 * 60_000,
      retry: (failureCount, error) =>
        error instanceof HttpError && error.status < 500 ? false : failureCount < 2,
    },
  },
});

export function useArticles(page: number) {
  return useQuery({
    queryKey: ['articles', page],
    queryFn: () => request<Article[]>('/articles?page=' + page),
    placeholderData: (previous) => previous,   // keep the old page visible while loading
  });
}

export function useAddArticle() {
  const client = useQueryClient();
  return useMutation({
    mutationFn: (draft: ArticleDraft) =>
      request<Article>('/articles', { method: 'POST', body: JSON.stringify(draft) }),
    onSuccess: () => client.invalidateQueries({ queryKey: ['articles'] }),
  });
}
OptionMeaningSet it to
staleTimeHow long data is considered freshAs long as the data realistically stays correct
gcTimeHow long unused cache is keptLonger than staleTime
retryAttempts after failureNever retry 4xx
refetchOnReconnectRefetch when the network returnsTrue for a mobile app

Two components requesting the same key share one in-flight request. That deduplication alone removes most duplicate network traffic in a screen with several cards.

Connectivity and an offline queue

import NetInfo from '@react-native-community/netinfo';

export function useIsOnline() {
  const [online, setOnline] = useState(true);
  useEffect(
    () =>
      NetInfo.addEventListener((state) => {
        setOnline(Boolean(state.isConnected) && state.isInternetReachable !== false);
      }),
    [],
  );
  return online;
}

// persist mutations so a queued write survives an app restart
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import AsyncStorage from '@react-native-async-storage/async-storage';

const persister = createSyncStoragePersister({
  storage: {
    getItem: AsyncStorage.getItem,
    setItem: AsyncStorage.setItem,
    removeItem: AsyncStorage.removeItem,
  },
});

// pause mutations while offline, resume when connectivity returns
const onlineManager = {
  setOnline: (value: boolean) => {
    if (value) queryClient.resumePausedMutations();
  },
};
⚠️
isConnected only means a network interface is up, not that the internet is reachable. Captive portals report a connection. Check isInternetReachable as well, and still handle a failed request as a normal case.

FAQ

How do I show a good offline experience?
Render cached data first with a small stale indicator, queue writes, and tell the user what is pending. An error screen with a retry button should be the exception, not the default when the cache holds usable data.
Why do my requests fail on Android emulator?
Usually a wrong host. Use 10.0.2.2 instead of localhost to reach the host machine, and remember that cleartext HTTP is blocked by default on modern Android.

Persistence and device APIs State management: Context, Redux Toolkit and Zustand

Last refreshed 2026-09-18.