← All articles
🔄 ArchitectureIntermediate · 12 min read

The Four Data-Fetching Patterns Every Growing App Needs

Every growing app reinvents the same four data-fetching patterns badly before someone introduces a real cache library. Naming them up front lets the team pick the right primitive the first time.

By Kajal Pansuriya · Reviewed by Kishan Vaghani · Published May 26, 2026

Every growing application reinvents the same four data-fetching patterns — usually badly, usually one at a time, usually as a tangle of useEffect calls — before someone finally introduces a real cache library. Naming the four patterns up front lets the team pick the right primitive the first time and stop arguing about whether the third fetch wrapper should also handle retries. The examples below use TanStack Query; the shapes translate to SWR or RTK Query line for line.

Pattern 1: Stable-key queries

Most data the frontend reads is identified by a stable key — a user ID, a project slug, a pair of route parameters. The cache library uses that key to deduplicate in-flight requests, to keep mounted components synchronised, and to know when to refetch. The minimum useful query looks like this:

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

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ["users", userId],
    queryFn: () => fetchUser(userId),
    staleTime: 60_000,
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorState error={error} />;
  return <Profile user={data} />;
}

Three defaults are worth knowing. staleTime is zero by default, which means every mount triggers a background refetch — usually too aggressive; sixty seconds is a good starting point. gcTime (formerly cacheTime) is five minutes by default, after which unused data is garbage collected. refetchOnWindowFocus is on by default; if your users keep your app open in a tab for hours, this is a feature, not a bug.

Two components that mount with the same queryKey share a single in-flight request. This is the most underrated property of the cache library: you can put useQuery({ queryKey: ['users', 42] }) in three different components on the same page and only one network request is made.

Pattern 2: Paginated lists with infinite scroll

Lists that the user scrolls or pages through use useInfiniteQuery. The shape adds two pieces: a pageParam that the queryFn uses to fetch the next page, and a getNextPageParam that pulls the continuation token out of the last response.

import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";

function IssueList({ projectId }) {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ["issues", projectId],
    queryFn: ({ pageParam }) => fetchIssues(projectId, pageParam),
    initialPageParam: null,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  // Flatten across pages — memoised so the rendered list does not change
  // identity on every render and trigger downstream re-renders.
  const issues = useMemo(
    () => data?.pages.flatMap((p) => p.items) ?? [],
    [data]
  );

  return (
    <>
      {issues.map((issue) => <IssueRow key={issue.id} issue={issue} />)}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? "Loading…" : "Load more"}
        </button>
      )}
    </>
  );
}

For real infinite scroll (no "Load more" button), wrap the fetchNextPage call in an intersection observer attached to a sentinel element at the bottom of the list. The observer fires when the sentinel enters the viewport; the cache library handles deduplication if the user scrolls fast enough to trigger it twice.

The most common bug is forgetting to memoise the flattened list. Without useMemo, every parent rerender creates a new array identity, and every row downstream rerenders even if its props are unchanged. The cost is invisible until the list grows past a few hundred rows.

Pattern 3: Mutations with optimistic updates

When a user toggles a star, renames a project, or marks a task done, the UI should reflect the change instantly. The mutation library pattern is onMutateonErroronSettled: snapshot the current cache, write the optimistic value, roll back on error, and refetch on settle to converge with the server.

import { useMutation, useQueryClient } from "@tanstack/react-query";

function useToggleStar(issueId) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (starred) => api.toggleStar(issueId, starred),

    onMutate: async (starred) => {
      // Cancel in-flight queries that could overwrite the optimistic update.
      await queryClient.cancelQueries({ queryKey: ["issues", issueId] });

      // Snapshot the current value so we can roll back.
      const previous = queryClient.getQueryData(["issues", issueId]);

      // Optimistically write the new value.
      queryClient.setQueryData(["issues", issueId], (old) =>
        old ? { ...old, starred } : old
      );

      // Return the rollback context to onError.
      return { previous };
    },

    onError: (err, _vars, context) => {
      if (context?.previous) {
        queryClient.setQueryData(["issues", issueId], context.previous);
      }
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["issues", issueId] });
    },
  });
}

The typical mistake is updating server data manually inonSuccess with whatever the mutation returned, then skipping the refetch. The optimistic value drifts from the server on the next page load. The cancel-snapshot-rollback-invalidate shape exists so you do not have to think about that drift —onSettled guarantees convergence.

Pattern 4: Cache invalidation rules

Every mutation invalidates the queries it affects. That is the rule, and it is short on purpose. The hard part is knowing the smallest set of queries that need invalidating.

// After updating user 42's name
queryClient.invalidateQueries({ queryKey: ["users", 42] });

// After deleting user 42
queryClient.invalidateQueries({ queryKey: ["users", 42] });
queryClient.invalidateQueries({ queryKey: ["users"] }); // the list view

// After creating a new comment on issue 17
queryClient.invalidateQueries({ queryKey: ["issues", 17, "comments"] });

Three rules keep this clean. First, invalidate the smallest possible key — ["users", 42] beats ["users"], which beats invalidating everything. Second, never call queryClient.invalidateQueries() with no key — that marks every query in the cache as stale and you will refetch the whole app. Third, structure your query keys hierarchically from the start: ["users", userId] and ["users", userId, "orders"] rather than flat strings; that way prefix invalidation works predictably.

What goes wrong without a cache layer

The hand-rolled equivalent is a useEffect that fetches and writes to local state. It looks simple. It is not.

// BEFORE — three bugs in eight lines
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then(setUser);
  }, [userId]);

  return user ? <Profile user={user} /> : <Skeleton />;
}

The bugs: stale data when the component unmounts mid-fetch and remounts (the old promise resolves and overwrites the new state); duplicate requests when two siblings mount with the same userId; no error state, so a 500 response renders an empty skeleton forever; no refetch on focus, so a user who comes back to the tab after lunch sees data from before lunch. The TanStack Query version handles all four for free.

When the library is overkill

Three situations where reaching for a cache library is the wrong move. Single-page tools — a calculator, a chart embed, anything that fits on one screen and has at most one server call — do not need a cache. Data that loads once and never changes — the build metadata baked into a static page, the list of countries — can live in a plain module-scope constant. Embedded widgets shipped inside a host application should not bring their own query client; they should accept the host's data through props.

The habit that compounds

When a new feature needs data, ask which of the four patterns it is before writing any code. Stable read? Pattern 1. Long list? Pattern 2. Write that updates the UI? Pattern 3. Cross-query effect? Pattern 4. Data fetching is the layer that ages worst when ignored and best when treated as a real architectural concern — name the patterns and the codebase stays workable as it grows.

Related reading

Data fetching is the server-state half of the broader state picture. The state-ownership post puts the cache layer in context with global and local state, the component-boundaries piece covers the surface that consumes this data, and the server-vs-client-components post covers when fetching should move to the server entirely. The full set lives under frontend-architecture.

About the writers

Author

Kajal Pansuriya

Developer Educator, ShareCode

Developer educator at ShareCode. Writes the tutorial track — Python, JavaScript debugging, coding-interview prep, and the everyday code-quality habits that hold up in real codebases.

Python fundamentals & teaching beginnersJavaScript debugging & DevToolsCoding-interview preparationClean code & code review
Reviewed by

Kishan Vaghani

Founder & Lead Engineer, ShareCode

Founder of ShareCode. Writes the engineering deep-dives on this site — WebRTC, Firebase Auth, real-time sync, and the production patterns behind the editor itself.

Real-time collaboration & CRDTsWebRTC & low-latency mediaFirebase authentication & security rulesNext.js & full-stack JavaScript

Moving a useEffect-based fetch into the cache?

Paste the existing component and the failing edge cases into a code space, share the link with a teammate, and walk through which of the four patterns fits. Most useEffect-based fetches collapse into eight lines of useQuery with two bugs already fixed.

Open a code space