Chapter 36 β Server State & Data Fetching
π Definition
Client state is owned by the browser: form inputs, open modals, selected theme. Server state is owned by the backend and only cached in the browser: users, products, orders. Server state can become stale, needs refetching, deduplication, pagination and invalidation β so it deserves a dedicated tool such as TanStack Query (React Query) or RTK Query, rather than useEffect + useState or a hand-written Redux slice.
π Explanation
Problems with useEffect fetching
useEffect(() => {
fetch(`/api/products?page=${page}`).then((r) => r.json()).then(setProducts);
}, [page]);This misses: loading and error state, race conditions (old page arriving after new), caching between screens, deduplication when two components need the same data, refetch on window focus, retries, and invalidation after a mutation.
Choosing a tool
| Need | Tool |
|---|---|
| Server state, any stack | TanStack Query |
| Already on Redux Toolkit | RTK Query |
| Small global client state | Zustand, Jotai, or Context |
| Complex client state with many actions, devtools, middleware | Redux Toolkit |
| Truly local state | useState / useReducer |
π» Code Example β TanStack Query Setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: 2, refetchOnWindowFocus: true },
},
});
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);π» Code Example β Queries
import { useQuery, keepPreviousData } from "@tanstack/react-query";
const fetchProducts = async ({ page, q }) => {
const res = await fetch(`/api/products?page=${page}&q=${encodeURIComponent(q)}`);
if (!res.ok) throw new Error("Failed to load products");
return res.json();
};
function ProductList({ q }) {
const [page, setPage] = useState(1);
const { data, isPending, isError, error, isFetching, isPlaceholderData } = useQuery({
queryKey: ["products", { page, q }], // cache key β changes trigger refetch
queryFn: () => fetchProducts({ page, q }),
placeholderData: keepPreviousData, // keep page 1 visible while page 2 loads
enabled: q.length !== 1, // conditional query
});
if (isPending) return <Spinner />;
if (isError) return <p>{error.message}</p>;
return (
<>
{data.items.map((p) => <ProductCard key={p._id} product={p} />)}
<button disabled={page === 1} onClick={() => setPage((p) => p - 1)}>Prev</button>
<button disabled={isPlaceholderData || !data.hasMore} onClick={() => setPage((p) => p + 1)}>Next</button>
{isFetching && <small>Refreshingβ¦</small>}
</>
);
}staleTime vs gcTime: staleTime is how long data counts as fresh (no refetch). gcTime (formerly cacheTime, default 5 minutes) is how long unused data stays in memory.
π» Code Example β Mutations With Invalidation
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddProduct() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (body) => fetch("/api/products", { method: "POST", body: JSON.stringify(body) }).then((r) => r.json()),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["products"] }), // refetch all product lists
});
return (
<button disabled={mutation.isPending} onClick={() => mutation.mutate({ name: "Pen", price: 20 })}>
Add
</button>
);
}π» Code Example β Optimistic Update With Rollback
const toggleTodo = useMutation({
mutationFn: (todo) => api.updateTodo(todo.id, { done: !todo.done }),
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previous = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
);
return { previous }; // context for rollback
},
onError: (_err, _todo, context) => queryClient.setQueryData(["todos"], context.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});π» Code Example β Infinite Scroll
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam }) => fetch(`/api/feed?cursor=${pageParam ?? ""}`).then((r) => r.json()),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
const posts = data?.pages.flatMap((p) => p.items) ?? [];
// Trigger fetchNextPage() from an IntersectionObserver on the last item.π» Code Example β Prefetching on Hover
<Link
to={`/products/${p._id}`}
onMouseEnter={() =>
queryClient.prefetchQuery({ queryKey: ["product", p._id], queryFn: () => fetchProduct(p._id) })
}
>
{p.name}
</Link>π» Code Example β Zustand for Client State
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useCart = create(
persist(
(set, get) => ({
items: [],
add: (product) =>
set((state) => {
const existing = state.items.find((i) => i.id === product.id);
return existing
? { items: state.items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)) }
: { items: [...state.items, { ...product, qty: 1 }] };
}),
remove: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((s, i) => s + i.price * i.qty, 0),
}),
{ name: "cart" }, // saved to localStorage
),
);
// Component subscribes only to what it selects β minimal re-renders
const count = useCart((s) => s.items.length);π» Code Example β Fixing the Race Condition Without a Library
useEffect(() => {
let ignore = false;
fetchUser(id).then((u) => { if (!ignore) setUser(u); });
return () => { ignore = true; }; // or AbortController
}, [id]);π Real-World Use
- Product listing with filters and pagination β
useQuerywith filters in thequeryKey. - Admin panel edits β
useMutation+invalidateQueries. - Social feed β
useInfiniteQuery+ IntersectionObserver. - Cart, theme, sidebar state β Zustand or Redux Toolkit.
π― Likely Interview Questions
- What is the difference between server state and client state?
- Why not store API data in Redux with
useEffectfetching? - Explain
staleTimevsgcTimein TanStack Query. - How do you refresh a list after creating an item? β Invalidate its query key.
- How do you implement an optimistic update with rollback?
- How do you avoid a race condition when fetching in
useEffect? - TanStack Query vs RTK Query β when would you pick each?
- Redux vs Zustand vs Context for global state?
β Advanced Component Patterns | Index | Next: Forms & Routing β