Data Fetching Patterns That Survive Contact With Production
useEffect + fetch works in a tutorial. Here's every way it breaks in a real app, and what to do instead.
Every React tutorial teaches this pattern. Every production codebase eventually discovers it's wrong in about six different ways.
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then(setUser)
.finally(() => setLoading(false));
}, [userId]);What's broken
- 01No error handling. A rejected promise here becomes an unhandled rejection and a component stuck loading forever.
- 02`fetch` doesn't reject on 4xx/5xx. A 500 response resolves fine,
.json()fails on the HTML error page, and your catch block reports a JSON parse error instead of a server error. - 03Race condition. Change
userIdquickly and responses can arrive out of order. The user sees data for the wrong record — silently, with no error anywhere. - 04No cleanup. Navigate away mid-flight and you set state on an unmounted component.
- 05No caching. Navigate back and it refetches, showing a spinner for data it had thirty seconds ago.
- 06Duplicate requests. Three components needing the same user fire three requests.
Fixing it by hand
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
(async () => {
try {
const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
if (!res.ok) throw new ApiError(res.status, await res.text());
setState({ status: 'success', data: await res.json() });
} catch (err) {
if (err.name === 'AbortError') return;
setState({ status: 'error', error: err });
}
})();
return () => controller.abort();
}, [userId]);That fixes the first four. It's also fifteen lines for one endpoint, and it still has no cache and no deduplication. Multiply by forty endpoints and you have a data layer nobody owns.
Use a query library
TanStack Query in React, resource or a query wrapper in Angular. This isn't a preference; the problems above are genuinely hard and genuinely solved.
export const userQuery = (id: string) => ({
queryKey: ['user', id] as const,
queryFn: ({ signal }) => api.users.get(id, { signal }),
staleTime: 60_000,
});
function UserProfile({ id }: { id: string }) {
const { data, isPending, error } = useQuery(userQuery(id));
// ...
}Extracting the query options into a factory is worth doing from day one. Prefetching on hover, invalidating after a mutation, and using it in a loader all become one-liners that reference the same key, and you never get the class of bug where two call sites spell a query key differently.
Mutations: invalidate, don't hand-patch
const updateUser = useMutation({
mutationFn: api.users.update,
onSuccess: (updated) => {
queryClient.setQueryData(['user', updated.id], updated);
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});Two operations with different intents. setQueryData writes the response you already have into the detail cache — no refetch needed. invalidateQueries marks the *list* stale, because the server may have derived fields you can't compute on the client. Trying to hand-patch a list is exactly how a client-side cache drifts from the database.
Optimistic updates: only where you can be confident
Optimistic updates are excellent for toggles and reorders — actions that essentially never fail and where the latency is the whole experience. They're a poor fit for anything with real server-side validation, because the rollback is jarring and the error arrives after the user has moved on.
Errors are a design problem, not a try/catch problem
Three distinct failure modes deserve three distinct treatments:
- Network failure — "Couldn't reach the server. Check your connection." with a retry button. Retry automatically once or twice first.
- 4xx — the request was wrong. Show the server's validation message next to the offending field. Never retry.
- 5xx — our fault. Apologise, offer retry, and log it with enough context to debug.
A single "Something went wrong" for all three is the frontend equivalent of catch (e) {}. It tells the user nothing, and it tells you nothing when they report it.
One more: don't fetch in a waterfall
A parent fetching a user, then rendering a child that fetches their orders, then rendering a grandchild that fetches order items — three sequential round trips, each waiting for the last. On a 200ms connection that's 600ms of pure latency before anything is visible. Fetch in parallel at the route level, or prefetch on the link hover, and the whole screen fills at once.