iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Data Fetching

Data fetching in React: choose between useEffect (basic, error-prone), a query library (TanStack Query, SWR), a router loader (React Router, Next.js), or server components. Each comes with a different cache + retry + revalidation story. Pick by the problem, not by habit.

useEffect vs TanStack Query vs server components

EXAMPLE
// 1) Bare useEffect — works, but you'll re-invent half a library
import { useState, useEffect } from 'react';

function Orders() {
  const [orders, setOrders] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true); setError(null);
    fetch('/api/orders', { signal: controller.signal })
      .then((r) => r.json())
      .then(setOrders)
      .catch((e) => { if (e.name !== 'AbortError') setError(e); })
      .finally(() => setLoading(false));
    return () => controller.abort();
  }, []);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return <ul>{orders.map((o) => <li key={o.id}>{o.customer}</li>)}</ul>;
}

// Bugs in this pattern:
// - No cache; revisiting the page refetches everything
// - No deduplication of identical requests
// - No retry on transient errors
// - No revalidation on window focus / network reconnect
// - You write the abort + race-condition handling on every screen

// 2) TanStack Query — the production default in 2026
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';

const qc = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 60_000, gcTime: 5 * 60_000, retry: 2 },
  },
});

function Root({ children }: { children: React.ReactNode }) {
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

function OrdersList() {
  const { data, isPending, error } = useQuery({
    queryKey: ['orders'],
    queryFn: async () => {
      const res = await fetch('/api/orders');
      if (!res.ok) throw new Error('HTTP ' + res.status);
      return res.json() as Promise<any[]>;
    },
  });

  if (isPending) return <p>Loading...</p>;
  if (error)    return <p>Error: {(error as Error).message}</p>;
  return <ul>{data!.map((o) => <li key={o.id}>{o.customer}</li>)}</ul>;
}

// 3) Mutation + cache invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query';

function CancelButton({ id }: { id: string }) {
  const qc = useQueryClient();
  const cancel = useMutation({
    mutationFn: () => fetch(\`/api/orders/${id}/cancel\`, { method: 'POST' }).then((r) => r.json()),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['orders'] });
      qc.invalidateQueries({ queryKey: ['orders', id] });
    },
  });
  return <button disabled={cancel.isPending} onClick={() => cancel.mutate()}>Cancel</button>;
}

// 4) Query keys are stable, structured arrays
useQuery({ queryKey: ['orders', { status: 'open', page: 1 }], queryFn: ... });
// invalidating ['orders'] re-fetches every query starting with 'orders'.

// 5) Router loaders — React Router or Next.js
// React Router (see react/router-routes lesson):
// loader: async () => fetch('/api/orders').then((r) => r.json())
// Pros: declarative, removes a class of useEffect bugs
// Cons: tied to your router

// 6) React Server Components / Next.js App Router
// app/orders/page.tsx
// export default async function OrdersPage() {
//   const orders = await db.orders.findMany();
//   return <OrdersList orders={orders} />;
// }
// Data is fetched on the server; no client JS for the fetch.
// Pros: smallest client bundle, leverages your server
// Cons: only works in supported frameworks; mental model shift

// 7) Optimistic updates with TanStack Query
useMutation({
  mutationFn: cancelOrder,
  onMutate: async (id) => {
    await qc.cancelQueries({ queryKey: ['orders'] });
    const previous = qc.getQueryData(['orders']);
    qc.setQueryData(['orders'], (old: any[]) =>
      old.map((o) => o.id === id ? { ...o, status: 'cancelled' } : o));
    return { previous };
  },
  onError: (_e, _id, ctx) => qc.setQueryData(['orders'], ctx?.previous),
  onSettled: () => qc.invalidateQueries({ queryKey: ['orders'] }),
});

// 8) Patterns to internalise
// - Don't use bare useEffect for production data fetching after week one
// - Query keys describe WHAT, params describe FILTERS
// - Invalidate on mutate; do not manually setQueryData unless you mean it
// - staleTime = 'how long can I show this without refetching?'
// - gcTime    = 'how long can I keep this in memory unused?'

// 9) Pitfalls
// - Forgetting AbortController in useEffect -> race conditions on fast navigation
// - useQuery in a useEffect (mostly an anti-pattern)
// - Using router loader AND useQuery for the same data -> double fetch
// - Server components with client-only data (cookies, headers) -> hydration mismatches

Why it matters

TanStack Query or a router loader removes the entire class of "I forgot the abort controller / race condition / stale state" bugs that bare `useEffect` ships with. Reach for a library on day one; the cost is one dependency, the benefit is cache, retry, revalidation, and abort handling for free.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
useEffect(() => {
    fetch('/api/users').then(r => r.json()).then(setUsers);
}, []);
Try it Yourself »

Discussion

Loading…