Learn Frontend Part 12: State Management and Data Fetching - Context, Redux Toolkit, Zustand and TanStack Query

Learn Frontend Part 12: State Management and Data Fetching - CodingHumans

Once an app grows past a few screens, two questions dominate: where should this state live? and how do we fetch, cache and keep server data fresh? Interviewers ask about Context vs Redux, why Zustand is popular, what TanStack Query solves, and how to handle loading, errors, caching and optimistic updates.

This is Part 12 of the Learn Frontend series, after React Hooks. We start with the most important idea, separating client state from server state, then compare the tools for each and finish with data-fetching patterns.

1. The kinds of state

Not all state is the same, and most state-management pain comes from treating it as one thing. Split it into categories:

KindExamplesBest home
Local UI stateIs the dropdown open, input text, active tabuseState / useReducer in the component
Shared client stateTheme, logged-in user, cart, sidebar collapsedLifted state, Context, Zustand, Redux Toolkit
Server stateProducts, user profile, comments from an APITanStack Query, SWR, RTK Query, framework loaders
URL stateFilters, search query, page number, selected tabQuery string and route params
Form stateField values, touched, validation errorsControlled inputs, React Hook Form

Two principles: keep state as local as possible, and move it up only when another component really needs it. And server state is a cache, not something you own. It can go stale, it is shared by many components, and it needs refetching, deduplication and invalidation. That is a different job from holding a boolean.

2. Use the URL for shareable state

If a user should be able to bookmark, share or refresh and see the same view, the state belongs in the URL. Search filters, sort order, pagination and the open tab are good candidates.

import { useSearchParams } from "react-router-dom";

function ProductFilters() {
  const [params, setParams] = useSearchParams();
  const sort = params.get("sort") ?? "popular";
  const page = Number(params.get("page") ?? 1);

  function changeSort(value) {
    setParams(prev => {
      prev.set("sort", value);
      prev.set("page", "1");          // reset page when the filter changes
      return prev;
    });
  }

  return (
    <select value={sort} onChange={e => changeSort(e.target.value)}>
      <option value="popular">Popular</option>
      <option value="price-asc">Price: low to high</option>
    </select>
  );
}

Benefits: the back button works, links are shareable, and you remove a whole class of "state is lost on refresh" bugs.

3. Context: dependency injection, not a store

Context (Part 11) solves prop drilling. It works well for values that change rarely, such as theme, locale and auth. Its limits show up with frequently changing data:

  • Every consumer re-renders when the provider value changes. There is no built-in way to subscribe to only part of the value.
  • Teams work around this by splitting contexts (state and dispatch separately) and memoising values, which adds boilerplate.
  • Context plus useReducer is a reasonable lightweight store for medium apps, but has no devtools, middleware or selectors.

4. Redux Toolkit

Redux keeps global state in a single store. You change it only by dispatching actions, and pure reducers calculate the next state. This makes every change traceable, and Redux DevTools allow time-travel debugging. Modern Redux means Redux Toolkit (RTK), which removed most of the old boilerplate:

import { configureStore, createSlice } from "@reduxjs/toolkit";
import { Provider, useSelector, useDispatch } from "react-redux";

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: [] },
  reducers: {
    added(state, action) {
      state.items.push(action.payload);   // looks mutable, Immer makes it immutable
    },
    removed(state, action) {
      state.items = state.items.filter(i => i.id !== action.payload);
    },
  },
});

export const { added, removed } = cartSlice.actions;
const store = configureStore({ reducer: { cart: cartSlice.reducer } });

function CartCount() {
  const count = useSelector(state => state.cart.items.length); // re-renders only when count changes
  return <span>{count}</span>;
}

function AddButton({ product }) {
  const dispatch = useDispatch();
  return <button onClick={() => dispatch(added(product))}>Add to cart</button>;
}

// <Provider store={store}><App /></Provider>
  • createSlice generates action creators and action types for you.
  • Reducers look like they mutate state, but RTK uses Immer to produce a new immutable state behind the scenes.
  • useSelector subscribes to just the slice you select, so components re-render only when that piece changes.
  • RTK Query adds data fetching and caching on top, similar to TanStack Query.
  • Choose Redux for large apps with many developers, complex shared state, and a need for strict patterns and great debugging.

5. Zustand and other lightweight stores

Zustand is a tiny store built on hooks. There are no providers, actions or reducers to set up, and components subscribe with selectors, so they re-render only when their selected slice changes.

import { create } from "zustand";

const useCart = create(set => ({
  items: [],
  add: item => set(state => ({ items: [...state.items, item] })),
  remove: id => set(state => ({ items: state.items.filter(i => i.id !== id) })),
  clear: () => set({ items: [] }),
}));

function CartCount() {
  const count = useCart(state => state.items.length);  // selector
  return <span>{count}</span>;
}

function AddButton({ product }) {
  const add = useCart(state => state.add);
  return <button onClick={() => add(product)}>Add</button>;
}

Other options you may hear about: Jotai and Recoil (atom-based, bottom-up state), MobX (observable, mutable style) and Valtio (proxy-based). In interviews, being able to explain the trade-off is more important than knowing every library:

ToolStrengthTrade-off
ContextBuilt in, no dependencyRe-renders all consumers, no selectors
Redux ToolkitPredictable, great devtools, scales to big teamsMore concepts and setup
ZustandMinimal API, selectors, no providerLess structure, so conventions are up to the team
JotaiFine-grained atoms, good for derived stateMany small atoms can be hard to track

6. Data fetching basics and their problems

The simplest approach, useEffect plus fetch plus useState, works, but in a real app you must solve all of these yourself:

  • Loading and error states for every request.
  • Race conditions when parameters change quickly (Part 11 showed the ignore flag and AbortController).
  • Caching, so going back to a page does not show a spinner again.
  • Deduplication, so three components that need the same user do not send three requests.
  • Stale data: refetching on window focus, reconnect or after a mutation.
  • Waterfalls: a parent fetches, renders, then a child starts its own fetch, so requests run one after another instead of in parallel.
  • Pagination, infinite scroll, retries with backoff, and prefetching.

This is exactly what server-state libraries are built for.

7. TanStack Query (React Query) and SWR

TanStack Query treats server data as a cache keyed by a query key. You declare what you need; the library fetches, caches, dedupes and refreshes it.

import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();
// <QueryClientProvider client={queryClient}><App /></QueryClientProvider>

function useTodos(filter) {
  return useQuery({
    queryKey: ["todos", filter],
    queryFn: ({ signal }) => fetch("/api/todos?filter=" + filter, { signal }).then(r => r.json()),
    staleTime: 60_000,          // treat data as fresh for 1 minute
  });
}

function TodoList({ filter }) {
  const { data, isPending, isError, refetch } = useTodos(filter);
  if (isPending) return <p>Loading...</p>;
  if (isError) return <button onClick={() => refetch()}>Retry</button>;
  return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}

function AddTodo() {
  const qc = useQueryClient();
  const mutation = useMutation({
    mutationFn: title => fetch("/api/todos", { method: "POST", body: JSON.stringify({ title }) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["todos"] }),  // refetch lists
  });
  return <button onClick={() => mutation.mutate("New task")}>Add</button>;
}

Key concepts:

  • Query key: an array that uniquely identifies the data. When a key part changes, a new query runs.
  • staleTime: how long data counts as fresh. Fresh data is served from cache without refetching. The default is 0, meaning data is stale immediately but still shown while refetching in the background.
  • gcTime (formerly cacheTime): how long unused data stays in memory.
  • Stale-while-revalidate: show cached data at once, refetch in the background, then update. SWR is named after this HTTP caching strategy.
  • Mutations and invalidation: after a POST or PUT, call invalidateQueries so related lists refetch.

8. Optimistic updates, pagination and errors

An optimistic update changes the UI immediately, assuming the server will succeed, and rolls back if it fails. It makes likes, toggles and reorders feel instant.

const likeMutation = useMutation({
  mutationFn: postId => api.like(postId),
  onMutate: async postId => {
    await qc.cancelQueries({ queryKey: ["post", postId] });
    const previous = qc.getQueryData(["post", postId]);
    qc.setQueryData(["post", postId], old => ({ ...old, likes: old.likes + 1, liked: true }));
    return { previous };                       // context for rollback
  },
  onError: (err, postId, context) => {
    qc.setQueryData(["post", postId], context.previous);   // roll back
  },
  onSettled: (data, err, postId) => {
    qc.invalidateQueries({ queryKey: ["post", postId] });  // sync with server
  },
});
  • Pagination: keep previous data visible while the next page loads (placeholderData: keepPreviousData) to avoid flicker.
  • Infinite scroll: useInfiniteQuery with a cursor from the API, plus an IntersectionObserver sentinel at the bottom of the list.
  • Errors: show a retry button, and use an Error Boundary to catch rendering errors so one broken widget does not blank the page.
  • Suspense: with useSuspenseQuery or React 19 use(), loading states move into <Suspense fallback> boundaries instead of if-statements in every component.

9. How to choose in an interview answer

A strong answer to "how would you manage state in this app?" usually sounds like this:

  1. Server data goes in a query library such as TanStack Query, or the framework's data layer (Next.js Server Components, Remix loaders).
  2. Shareable view state goes in the URL.
  3. Form state uses controlled inputs or React Hook Form with schema validation (Zod).
  4. Everything else stays local, lifted only as far as needed.
  5. What is left, truly global client state, is usually small. Context is enough for rarely changing values; Zustand or Redux Toolkit for frequently changing or complex shared state.

Practise this on system design questions such as the frontend system design interview guide.

Key takeaways
  • Separate local UI state, shared client state, server state, URL state and form state. Each has a different best home.
  • Keep state as local as possible and lift it only when needed.
  • Server state is a cache. Use TanStack Query, SWR or RTK Query for caching, dedupe, refetching and invalidation.
  • Put shareable view state such as filters and pages in the URL.
  • Context is great for rarely changing values but re-renders every consumer on change.
  • Redux Toolkit gives structure and devtools for large apps; Zustand gives a minimal store with selectors.
  • Optimistic updates make the UI feel instant; always keep a rollback path.

Practice problems

Build these to get comfortable with each tool. Reuse a free fake API such as JSONPlaceholder for the data.

  1. Build a product list where search, sort and page are stored in the URL and survive a refresh.
  2. Build the same shopping cart three times: with Context plus useReducer, with Zustand and with Redux Toolkit. Compare the code.
  3. Fetch a list with useEffect, then rewrite it with TanStack Query and add caching, retry and refetch on focus.
  4. Add an optimistic "like" button with rollback on error.
  5. Build an infinite-scroll feed with useInfiniteQuery and IntersectionObserver.
  6. Wrap a widget that throws in an Error Boundary with a "Try again" button.

Interview questions and FAQ

When should you use Redux instead of Context?

Use Context for values that change rarely, such as theme or the current user. Use Redux Toolkit, or a store like Zustand, when shared state changes often, many components need different slices of it, or you need middleware, devtools and strict patterns in a large team.

What is the difference between client state and server state?

Client state is owned by the browser, such as UI toggles or a draft form. Server state lives on the server; the client only holds a possibly stale copy, so it needs caching, refetching and invalidation. Libraries like TanStack Query exist for server state.

What problem does TanStack Query solve?

It handles caching, request deduplication, background refetching, stale data, retries, pagination and mutations with invalidation, so you do not need to rebuild all of that with useEffect and useState in every component.

What is prop drilling and how do you avoid it?

Passing props through several components that do not use them, just to reach a deep child. Avoid it with composition (passing components as children), Context, or a store.

What is an optimistic update?

Updating the UI immediately as if a request succeeded, then rolling back if it fails. It makes interactions feel instant and is common for likes, toggles and reordering.

What are request waterfalls?

When requests that could run in parallel run one after another, usually because a child only starts fetching after its parent finishes. Fix it by fetching higher up, in parallel, by prefetching, or by using route loaders and Server Components.

Comments

Answered 0/0Correct 0