Skip to content
    All posts
    Frontend

    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.

    3 min readby

    Every React tutorial teaches this pattern. Every production codebase eventually discovers it's wrong in about six different ways.

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

    What's broken

    1. 01No error handling. A rejected promise here becomes an unhandled rejection and a component stuck loading forever.
    2. 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.
    3. 03Race condition. Change userId quickly and responses can arrive out of order. The user sees data for the wrong record — silently, with no error anywhere.
    4. 04No cleanup. Navigate away mid-flight and you set state on an unmounted component.
    5. 05No caching. Navigate back and it refetches, showing a spinner for data it had thirty seconds ago.
    6. 06Duplicate requests. Three components needing the same user fire three requests.

    Fixing it by hand

    tsx
    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.

    tsx
    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

    tsx
    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.

    ReactTanStack QueryAPIError Handling

    Keep reading

    PAPulapa Arun Kumar

    Full-stack developer building performant, clean, and user-friendly web & mobile applications. Also written as Arun Kumar Pulapa — same person, surname first.

    Built with

    ReactTypeScriptTailwind CSSVite

    © 2026 Pulapa Arun Kumar. All rights reserved.