Fetching Data in React: useEffect, SWR, and TanStack Query
Fetch data in React without the usual bugs: race conditions in useEffect, AbortController cleanup, and what SWR and TanStack Query actually cache for you.
Fetching data in React is four lines of code. Handling loading states, errors, race conditions, caching, refetching and cleanup around those four lines is the actual job, and it is where most React codebases accumulate their quietest bugs. This post walks the path from a raw fetch in useEffect to a query library, and is explicit about which problem each step solves.
The baseline: fetch inside useEffect
Here is the version that appears in nearly every tutorial:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
return <h2>{user?.name}</h2>;
}It works in a demo. It has four distinct bugs.
Bug one: it never handles failure
fetch only rejects on network failure. A 404 or a 500 resolves normally, so res.json() runs against an error body, and you either render undefined or throw a parse error with no context. You have to check res.ok yourself. This is the single most common mistake with the fetch API, and it is inherited from the platform, not from React.
Bug two: the race condition
This is the one that reaches production. Change userId from a to b quickly and two requests are in flight. If a is slower and lands second, you render user a's data on a page for user b. Nothing errors. The UI is simply wrong, intermittently, in a way that never reproduces on a fast local connection against a local API.
Bug three: no cleanup
If the component unmounts mid-flight, the setUser call still runs. React 18 stopped warning about this, which made it easier to ignore, but the request is still wasted and any state derived from it is still stale.
Bug four: loading never resets
When userId changes, loading stays false from the previous fetch, so the component shows the old user's name until the new response lands. No spinner, just wrong data presented confidently.
The corrected version
AbortController fixes the race and the cleanup together:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => {
// fetch does not reject on 4xx/5xx, so check this yourself.
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
})
.then((data: User) => {
setUser(data);
setLoading(false);
})
.catch((err: unknown) => {
// An aborted request is expected, not an error worth showing.
if (err instanceof Error && err.name === 'AbortError') return;
setError(err as Error);
setLoading(false);
});
return () => controller.abort();
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p role="alert">Could not load this user.</p>;
return <h2>{user?.name}</h2>;
}Twenty-nine lines to fetch one object correctly, and this still only covers a single component fetching a single resource once.
Why it fires twice in development
In development with Strict Mode on, React mounts, unmounts and remounts every component, so effects run twice and you see two requests in the network tab. This is deliberate, and it surfaces exactly the missing-cleanup bug above. If the second run breaks something, the effect is not idempotent and that is a real defect, not a React quirk. It does not happen in production builds. Do not "fix" it with a ref guard.
What you still do not have
Suppose you extract all of that into a useFetch hook. Reasonable, and it is where most teams stop. Here is what the hook still does not do:
- Caching. Two components asking for the same user make two requests. Navigate away and back, and it refetches from scratch with a spinner.
- Deduplication. Three components mounting at once fire three identical requests.
- Revalidation. Data goes stale while the tab sits in the background and nothing refreshes it.
- Retries. One flaky response and the user gets an error state with a reload button.
Every one of these is solvable by hand. Solving all of them well is rebuilding a query library, and there are two good ones.
SWR
SWR is the smaller of the two, from the Next.js team. The name is the strategy: stale-while-revalidate. Serve whatever is cached immediately, fetch in the background, re-render if the result differs.
import useSWR from 'swr';
const fetcher = async (url: string) => {
const res = await fetch(url);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
};
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading } = useSWR<User>(
`/api/users/${userId}`,
fetcher
);
if (isLoading) return <p>Loading...</p>;
if (error) return <p role="alert">Could not load this user.</p>;
return <h2>{data?.name}</h2>;
}The race condition is gone, the request is deduplicated across components, the result is cached by URL, and it revalidates on window focus and reconnect by default. That last default surprises people. alt-tab back to the browser and SWR refetches. It is usually what you want and occasionally very much not, and it is one option away.
TanStack Query
TanStack Query does the same job with more surface area: mutations, infinite queries, optimistic updates, prefetching, a proper devtools panel. It is what I default to on anything beyond a small app.
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data, error, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json() as Promise<User>;
},
staleTime: 60_000,
});
if (isPending) return <p>Loading...</p>;
if (error) return <p role="alert">Could not load this user.</p>;
return <h2>{data.name}</h2>;
}staleTime and gcTime are not the same thing
This is the most common misunderstanding in the library, and it is worth getting right because the defaults surprise people.
staleTime is how long data is considered fresh. While fresh, mounting another component with the same key triggers no request at all. It defaults to 0, meaning data is stale the moment it arrives, which is why people install the library, change nothing, and report that it "fetches constantly". It is doing what they asked.
gcTime is how long an unused cache entry survives after the last component using it unmounts. It defaults to five minutes. This was called cacheTime until v5 renamed it, so older Stack Overflow answers use a key that no longer exists and fails silently as an unknown option.
Set staleTime deliberately, per query. A user profile can be fresh for a minute; a stock price cannot.
The query key is the cache key
Everything in the cache is addressed by queryKey. Get it wrong and the symptoms are confusing: omit userId from the key and every user shares one cache entry, so switching users shows the previous one until the refetch resolves. That is the race condition from the top of this post, reintroduced through the cache. Every value the queryFn closes over belongs in the key.
Once the key is right, keeping data current is configuration rather than code, including polling in React with TanStack Query, which is a single refetchInterval option.
Seeing the cache with React Query Devtools
Cache behaviour is invisible until you look at it, and almost every "why did this not refetch?" question answers itself in the devtools panel. It ships separately and is stripped from production builds automatically:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}The panel lists every cached query with its key and current state (fresh, stale, fetching or inactive) and lets you inspect the cached data directly. Two things it settles in seconds: whether a key is what you think it is (a stray object in the array produces a different key than expected), and whether a query is stale or merely inactive, which is the distinction behind most surprising refetches. There is no official SWR equivalent, which is a real part of the choice below.
Mutations: useMutation and invalidateQueries
Reads are the easy half. The moment you write, the cache you just gained becomes something that can lie to the user. You save a name, the request succeeds, and the list still shows the old one because nothing told the cache it was wrong.
import { useMutation, useQueryClient } from '@tanstack/react-query';
function RenameUser({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: async (name: string) => {
const res = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error('Rename failed');
return res.json() as Promise<User>;
},
onSuccess: () => {
// Mark anything keyed under this user as stale, so it refetches.
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
return (
<button disabled={isPending} onClick={() => mutate('Ada')}>
Rename
</button>
);
}invalidateQueries does prefix matching, so ['user'] invalidates every user query while ['user', userId] invalidates one. Getting that prefix wrong is how you end up refetching an entire dashboard after editing a single field. Structure keys broad-to-narrow and this stays predictable.
The waterfall you cannot see
A subtler problem, and one no library fixes for you. If a parent fetches a user and a child fetches that user's posts, the child cannot start until the parent finishes. Two sequential round trips where one pair of parallel ones would do. It looks fine in code because each component is self-contained; it shows up as a page that takes 900 ms to become useful on a connection where each request takes 400.
Fetch both at the level that knows about both, with useQueries or a pair of hooks in the same component, and pass the results down. This is the strongest argument for fetching on the server, where the waterfall collapses entirely.
When one query genuinely needs another
Sometimes the waterfall is real and unavoidable: you need the user before you can ask for their organisation. Do not fake it with a nested component or an early return. The query still mounts, fires with an undefined id, and caches a failure under a key you did not intend. Gate it explicitly:
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: fetchUser,
});
const { data: org } = useQuery({
queryKey: ['org', user?.orgId],
queryFn: () => fetchOrg(user!.orgId),
enabled: Boolean(user?.orgId), // stays idle until the id exists
});With enabled false the query reports isPending without ever running, which is the state you want while the dependency resolves. SWR expresses the same idea by returning null from the key function.
SWR vs TanStack Query
| SWR | TanStack Query | |
|---|---|---|
| Size (gzipped) | ~4 kB | ~13 kB |
| Mutations | Manual, via mutate | useMutation, built in |
| Devtools | None official | Yes, and genuinely good |
| Infinite / paginated | useSWRInfinite | useInfiniteQuery |
| Best fit | Read-heavy apps | Apps that also write |
If the app mostly reads, SWR is less to learn and less to ship. If it creates and updates things, take TanStack Query. The devtools alone pay for the extra 9 kB the first time you have to explain why a list did not refresh after a save.
The fetch you should not write at all
In the Next.js App Router, a Server Component can fetch directly. No hook, no loading state, no client-side waterfall:
// app/users/[id]/page.tsx (a Server Component)
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const res = await fetch(`https://api.example.com/users/${id}`, {
next: { revalidate: 60 },
});
if (!res.ok) throw new Error('Failed to load user');
const user: User = await res.json();
return <h2>{user.name}</h2>;
}The data arrives in the HTML, the request never touches the browser, and the API token stays on the server. Reach for a query library when the data changes in response to user interaction after load: filters, pagination, live updates, anything you would otherwise refetch. Fetching on the server first and hydrating a query client with the result is the combination worth learning, and it is why "which library" is less interesting than "which side of the boundary".
React 19's use() can unwrap a promise passed from a Server Component to a client one, which covers the handoff without a library. It does not cache or revalidate, so it complements a query client rather than replacing it.
What I reach for
Server Components for anything known at request time. TanStack Query for client-side data in an app that writes, SWR when it only reads, and a hand-rolled useEffect fetch essentially never, not because it cannot be done correctly, but because doing it correctly costs thirty lines per call site and one of them will eventually be missing the abort.
Keep server data out of React Context and out of your React state management store. Cached remote data has different rules from client state, and tools that know it is remote handle those rules for you.


