Skip to main content
cd ../blog
TanStack QueryReactReact NativeAPIData Fetching

Why TanStack Query is the first thing I reach for on any API-driven app

By Emeka OkezieJul 19, 20267 min read109 views
Why TanStack Query is the first thing I reach for on any API-driven app

For the longest time, every screen I built that talked to an API looked the same. A useState for the data, another for loading, one more for error, a useEffect to kick off the fetch, and a mental note to cancel it on unmount. Multiply that across a dashboard with fifteen widgets and half the codebase is just plumbing, plus a long tail of subtle bugs: stale data after a save, the same endpoint fetched five times on one screen, spinners flashing over data that was already cached.

TanStack Query (formerly React Query) is the tool that made all of that disappear for me. This is the write-up I wish I'd had when I started: what it is, how to install and set it up, how I use it on both web and mobile, and why I now reach for it on every project.

The shift that made it click: server state isn't client state

The idea that finally landed for me is this: the data your API owns is not the same kind of state as the toggle on a dropdown. Server data is fetched asynchronously, shared across screens, can go stale the moment someone else changes it, and needs caching, retries, and background refresh. Trying to model all of that with useState and useEffect is why the plumbing kept growing.

TanStack Query treats server data as its own category, "server state", and gives you a cache that knows how to fetch it, dedupe it, keep it fresh, and hand it to any component that asks. You stop writing fetch lifecycles and start describing what you need.

Installing it (web)

npm install @tanstack/react-query
# the devtools are worth it from day one
npm install -D @tanstack/react-query-devtools

Setting up the client

You create one QueryClient for the whole app and wrap your tree in a provider. On the Next.js App Router I create the client inside useState so it isn't shared across requests on the server:

app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [client] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // treat data as fresh for 1 minute
            retry: 2,
          },
        },
      })
  );
 
  return (
    <QueryClientProvider client={client}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Then drop it into the root layout:

app/layout.tsx
import { Providers } from "./providers";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Your first query

Here's a whole data-fetching component. Watch how much isn't there:

components/Projects.tsx
"use client";
import { useQuery } from "@tanstack/react-query";
 
async function getProjects() {
  const res = await fetch("/api/projects");
  if (!res.ok) throw new Error("Failed to load projects");
  return res.json();
}
 
export function Projects() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["projects"],
    queryFn: getProjects,
  });
 
  if (isPending) return <p>Loading…</p>;
  if (isError) return <p>{error.message}</p>;
 
  return (
    <ul>
      {data.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

No useState, no useEffect, no manual cancellation. Render this component in three different places and the request fires once, and the rest read from cache. Navigate away and back and it serves cached data instantly while quietly refetching in the background.

Query keys are the whole game

The one concept worth slowing down for is the query key. It's the cache's identity for a piece of data, and its dependency list. Same key, same cached entry, shared everywhere. Change anything in the key and Query treats it as different data and fetches again:

useQuery({
  queryKey: ["project", projectId],
  queryFn: () => getProject(projectId),
  enabled: !!projectId, // don't run until we actually have an id
});

Once this clicked, enabled for dependent queries and structured keys like ["projects", { status: "active" }] became my everyday tools.

Mutations, and keeping the cache honest

Reads are half of it. When you change data, you tell Query which cache entries are now stale and it refetches them:

components/NewProject.tsx
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
 
export function NewProject() {
  const queryClient = useQueryClient();
 
  const { mutate, isPending } = useMutation({
    mutationFn: (body: { title: string }) =>
      fetch("/api/projects", {
        method: "POST",
        body: JSON.stringify(body),
      }).then((r) => r.json()),
    onSuccess: () => {
      // the list is now out of date, so refetch it
      queryClient.invalidateQueries({ queryKey: ["projects"] });
    },
  });
 
  return (
    <button disabled={isPending} onClick={() => mutate({ title: "New" })}>
      {isPending ? "Saving…" : "Add project"}
    </button>
  );
}

That invalidateQueries call is the pattern that killed an entire class of "why is the UI showing old data?" bugs for me. For that extra-snappy feel you can go further with optimistic updates (write the change into the cache before the server responds, roll back onError), but plain invalidation covers most screens.

The two knobs that matter: staleTime vs gcTime

  • staleTime: how long fetched data is considered fresh. While fresh, Query serves it from cache and won't refetch. It defaults to 0, which surprised me at first (everything refetches on focus/mount). Setting a sane default calmed a lot of chatter.
  • gcTime: how long unused data lingers in the cache before it's garbage-collected. This is what lets a screen you left five seconds ago paint instantly when you come back.

Getting these two right is 90% of tuning Query.

Mobile: React Native / Expo

Here's the part that sold me for good. The data layer is basically identical on native. Same install, same hooks, same mental model:

npx expo install @tanstack/react-query

The provider setup is the same as web. There are just two platform bits to wire up, because a phone has no browser to tell Query about network and focus.

1. Report online/offline status via NetInfo:

lib/online.ts
import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";
 
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected);
  });
});

2. Refetch when the app returns to the foreground:

lib/focus.ts
import { AppState } from "react-native";
import { focusManager } from "@tanstack/react-query";
 
AppState.addEventListener("change", (status) => {
  focusManager.setFocused(status === "active");
});

If you want the app to open with data already on screen (offline-first), persist the cache to AsyncStorage:

npx expo install @tanstack/react-query-persist-client \
  @tanstack/query-async-storage-persister \
  @react-native-async-storage/async-storage
App.tsx
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import AsyncStorage from "@react-native-async-storage/async-storage";
 
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
 
export default function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister }}
    >
      <RootNavigator />
    </PersistQueryClientProvider>
  );
}

Everything else, every useQuery and useMutation in the app, is byte-for-byte the same as the web version. When you maintain a web app and a mobile app that hit the same API, that shared surface is enormous.

What I actually learned along the way

  1. staleTime: 0 is the default. Decide your freshness policy on purpose instead of being surprised by refetches.
  2. Keys are dependencies. Put every variable the query depends on in the key. Forgetting to is the source of most "why is it showing the wrong data?" moments.
  3. Invalidate; don't hand-roll cache writes. Reach for invalidateQueries first. Manual setQueryData is for optimistic updates, not everyday flows.
  4. enabled is how you sequence dependent queries. Don't fetch the profile until you have the user id.
  5. select narrows and transforms data without extra re-renders, so components only re-render when the slice they care about changes.
  6. Install the devtools immediately. Seeing the cache, key by key, turned Query from magic into something I could reason about.

Why I recommend it to anyone building on an API

I recommend TanStack Query because it deletes code and deletes bugs at the same time, a rare combination. The loading/error/caching/refetch logic you'd otherwise write by hand (and get subtly wrong) becomes a few declarative lines. Data is deduped and cached for free, the UI stays fresh without you orchestrating it, and best of all, the exact same data layer runs on web and React Native.

If your app talks to an API and you're still managing that with useState and useEffect, this is the single highest-leverage upgrade you can make. It's the first dependency I add to a new project now, and I haven't looked back.