Home / Articles / A Minimal useFetch Hook: When You Can Skip React Query, and Its Limits

This article is published in English.

A Minimal useFetch Hook: When You Can Skip React Query, and Its Limits

Build a small cached useFetch hook with AbortController, TTL expiry and a useMutation companion, and learn exactly which React Query features you give up.

2016 words

React Query (now TanStack Query) has become the reflexive answer to data loading in React, and it is excellent software. Yet in many codebases nearly every call does the same thing: fetch, show a spinner, show an error, avoid refetching. This guide builds a small, dependency-free useFetch hook for that case, adds TTL caching and mutations, and is honest about the gaps you inherit, so you can decide per project rather than by default.

What typical usage looks like

Often most React Query usage is one pattern: a key plus a function that fetches and parses JSON.

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});

That needs only loading, errors and caching. The library also ships background refetching, invalidation, pagination and infinite-scroll helpers, optimistic updates, devtools, server state synchronization and mutations with rollback. If none are used, you pay bundle size and complexity for three variables. Check the current bundle size yourself; figures vary by release.

What React Query is genuinely good at

Its core job is server state management, keeping the UI in sync with data on a server. It excels at:

  • Background refetching while the user is on the page.
  • Refreshing related queries after a mutation.
  • Pagination and infinite scroll.
  • Optimistic updates before the server confirms.
  • Deduplication: two components asking for the same data trigger one request.

If you need these, it earns its place. Many screens only need to fetch on mount, show loading, handle failure and not refetch needlessly.

Why hand-written effects fall short

The classic approach uses three state variables and an effect:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

It works, with weaknesses:

  • Stale responses. If the component unmounts or userId changes mid-request, the old response still calls setState. React 18 dropped the unmounted-update warning, but a slow response for a previous id can still overwrite newer data.
  • No deduplication. Two components, two requests.
  • No caching. Returning to the screen fetches again.
  • Repetition in every component.

Building the useFetch hook

The hook keeps a module-level Map keyed by URL. Initial state is read from it synchronously, so cached data renders without a loading flash. In the effect, a cache hit returns early; otherwise an AbortController signal goes with the request, non-2xx statuses become errors, and results are cached. Cleanup aborts the request when the URL changes or the component unmounts, AbortError is ignored, and a falsy URL skips fetching.

// useFetch.js
import { useState, useEffect, useRef } from 'react';

const cache = new Map();

export function useFetch(url, options = {}) {
  const [state, setState] = useState({
    data: cache.get(url) ?? null,
    loading: !cache.has(url),
    error: null
  });

  const abortRef = useRef(null);

  useEffect(() => {
    if (!url) return;
    if (cache.has(url)) {
      setState({ data: cache.get(url), loading: false, error: null });
      return;
    }

    abortRef.current = new AbortController();

    setState(prev => ({ ...prev, loading: true, error: null }));

    fetch(url, { signal: abortRef.current.signal, ...options })
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(data => {
        cache.set(url, data);
        setState({ data, loading: false, error: null });
      })
      .catch(err => {
        if (err.name === 'AbortError') return;
        setState({ data: null, loading: false, error: err.message });
      });

    return () => abortRef.current?.abort();
  }, [url]);

  return state;
}

Though billed as "25 lines", it is closer to forty, still small. It provides loading state, error handling, caching, cancellation of stale requests (see race conditions that debouncing cannot solve), cleanup on unmount and instant cached renders.

Two caveats: options is missing from the effect dependencies, so changing headers alone will not refetch, and the URL-only cache key lets requests with different bodies or auth headers collide.

Using the hook

Data access becomes one line. The shape resembles React Query's, but the flag is loading, not isLoading.

function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

Two components, one URL

Here a header and sidebar read the same user.

function Header({ userId }) {
  const { data: user } = useFetch(`/api/users/${userId}`);
  return <div>Hello, {user?.name}</div>;
}

function Sidebar({ userId }) {
  const { data: user } = useFetch(`/api/users/${userId}`);
  return <div>{user?.email}</div>;
}

This is where the hook overpromises. If both mount together, neither finds a cache entry, so two requests go out; only later mounts benefit. Real deduplication means caching the in-flight promise, not just the resolved data.

Conditional and parameterized fetching

Passing null disables the request, like React Query's enabled option.

function UserPosts({ userId, enabled }) {
  const { data, loading } = useFetch(enabled ? `/api/users/${userId}/posts` : null);
  // Pass null to skip fetching
}

Since the URL is the effect dependency, a URL built from props refetches when category or page change. Encode user input with encodeURIComponent.

function ProductList({ category, page }) {
  const url = `/api/products?category=${category}&page=${page}`;
  const { data, loading, error } = useFetch(url);
  // Refetches automatically when category or page changes
}

A dashboard with three sources

Three calls run in parallel, each section with its own skeleton, no provider or configuration, and revisits render from cache.

function Dashboard({ userId }) {
  const { data: user, loading: userLoading } = useFetch(`/api/users/${userId}`);
  const { data: stats, loading: statsLoading } = useFetch(`/api/stats/${userId}`);
  const { data: activity, loading: actLoading } = useFetch(`/api/activity/${userId}`);

  return (
    <div>
      <Header>
        {userLoading ? <Skeleton /> : <UserInfo user={user} />}
      </Header>

      <StatsPanel>
        {statsLoading ? <Skeleton /> : <Stats data={stats} />}
      </StatsPanel>

      <ActivityFeed>
        {actLoading ? <Skeleton /> : <Activity items={activity} />}
      </ActivityFeed>
    </div>
  );
}

Expiring entries with a TTL

A never-expiring cache is wrong for changing data. Storing a timestamp per entry and dropping entries older than five minutes fixes that:

const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

function setCache(url, data) {
  cache.set(url, { data, timestamp: Date.now() });
}

function getCache(url) {
  const entry = cache.get(url);
  if (!entry) return null;
  if (Date.now() - entry.timestamp > CACHE_TTL) {
    cache.delete(url);
    return null;
  }
  return entry.data;
}

The hook then reads and writes through these helpers; only the changed parts are shown:

const cached = getCache(url);

if (cached) {
  setState({ data: cached, loading: false, error: null });
  return;
}

// ... fetch logic ...
.then(data => {
  setCache(url, data);
  setState({ data, loading: false, error: null });
})

For per-call lifetimes, extract ttl from the options and pass the rest to fetch: short for prices, long for profiles. The example declares data twice in one scope only to show two usages.

export function useFetch(url, options = {}) {
  const { ttl = 5 * 60 * 1000, ...fetchOptions } = options;
  // Use ttl in cache logic
}

// Usage
const { data } = useFetch('/api/live-prices', { ttl: 30 * 1000 }); // 30 second cache
const { data } = useFetch('/api/user-profile', { ttl: 60 * 60 * 1000 }); // 1 hour cache

Expired entries are deleted only when read, so the Map is unbounded in a long-running app that touches many URLs.

A companion useMutation hook

Writes are triggered by the caller rather than on mount. This hook tracks loading, error and data, sends a JSON body, treats non-2xx as failure, and both stores and rethrows errors.

// useMutation.js
import { useState } from 'react';

export function useMutation(url, method = 'POST') {
  const [state, setState] = useState({
    loading: false,
    error: null,
    data: null
  });

  const mutate = async (body) => {
    setState({ loading: true, error: null, data: null });

    try {
      const response = await fetch(url, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body)
      });

      if (!response.ok) throw new Error(`HTTP ${response.status}`);

      const data = await response.json();
      setState({ loading: false, error: null, data });
      return data;
    } catch (err) {
      setState({ loading: false, error: err.message, data: null });
      throw err;
    }
  };

  return { ...state, mutate };
}

In the form, the button is disabled during the request and errors render below. Note that onSubmit receives an event, not form data, so real code must call event.preventDefault() and build the body, for example from new FormData(event.currentTarget).

function CreatePost() {
  const { mutate, loading, error } = useMutation('/api/posts');

  const handleSubmit = async (formData) => {
    try {
      const post = await mutate(formData);
      console.log('Created:', post);
    } catch (err) {
      // error state handles this
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button disabled={loading}>
        {loading ? 'Creating...' : 'Create Post'}
      </button>
      {error && <p>{error}</p>}
    </form>
  );
}

A successful mutation does not touch the useFetch cache, so related lists stay stale until their TTL expires or you clear keys manually.

What you give up

Background refetching

React Query refetches on window focus. You can approximate it by deleting the cache entry on focus, but this sketch alone does not rerun the effect; you also need a trigger such as a state counter.

useEffect(() => {
  const handleFocus = () => {
    cache.delete(url); // Invalidate cache
    // Trigger refetch
  };
  window.addEventListener('focus', handleFocus);
  return () => window.removeEventListener('focus', handleFocus);
}, [url]);

Optimistic updates, devtools, pagination and invalidation

  • Optimistic updates with correct rollback are hard, and the library clearly wins here.
  • Devtools show cache, queries and timings; the homemade version has console.log.
  • useInfiniteQuery is well designed; custom pagination takes noticeably more work.
  • Global invalidation, such as refetching every "posts" query, becomes manual cache clearing.

Choosing between the two

Prefer React Query for mutation-driven invalidation, background refetching, infinite scroll or complex pagination, devtools, or when your team already knows it. A small hook fits when screens mostly fetch and display, permanent or TTL caching suffices, zero dependencies matters, and the app is small to medium. If you stay with the library, see structuring a TanStack Query data layer.

Migrating incrementally

Move one component at a time: the import and hook call change, plus the isLoading to loading rename.

// Before
import { useQuery } from '@tanstack/react-query';

const { data, isLoading, error } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});

// After
import { useFetch } from './useFetch';

const { data, loading, error } = useFetch(`/api/users/${userId}`);

When nothing imports the library any more, uninstall it.

npm uninstall @tanstack/react-query

Key takeaways

  • Most fetching is simple, and a Map with timestamps is a legitimate cache for simple needs.
  • AbortController is the native fix for stale, out-of-order responses.
  • A minimal hook caches results, not in-flight requests, so simultaneous mounts still duplicate calls.
  • Every library option is surface area; adopt a tool for what it does, not out of habit.
  • Ask whether the project needs anything beyond basic fetching, and choose accordingly.