Skip to content
NM. Nirmal Maharjan, home
Back to blog

Three things React Query taught me on a team e-commerce build

5 min read

From April to June 2026 I was one of several developers on Luxe, a multi-vendor e-commerce platform built with Next.js and TanStack Query (React Query). I built the account and order pages, search, the blog and the brand and sale pages, and I worked on the wishlist and checkout.

React Query looks simple: give it a key and a function, get data back. Most of what I learned came from three places where I used it wrong first. The snippets below are simplified, but each mistake and each fix comes from my own commits.

1. Create the QueryClient once

This was in our providers component:

providers.tsx (before)
export function Providers({ children }: { children: ReactNode }) {
  const queryClient = new QueryClient();
 
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

It looks harmless, but this line runs on every render. Each time the providers component re-rendered, React Query got a brand-new client with an empty cache, so data the app had already fetched could be thrown away and fetched again.

The fix keeps one client for the life of the component:

providers.tsx (after)
const [queryClient] = useState(() => new QueryClient());

useState with a function runs that function only on the first render. This is the setup the TanStack Query docs recommend for Next.js, and now I check for it first in any project I join.

2. Don't use the cache as a store

The wishlist shows a heart on every product card, and a count in the navbar. Many components need the same answer: which products has this user saved?

My first version put the saved product IDs in React Query's cache, but the query never fetched them:

First version
const { data: wishlistIds } = useQuery({
  queryKey: ["wishlist", "productIds", userId],
  queryFn: () => [],
  initialData: [],
  staleTime: Number.POSITIVE_INFINITY,
});

The list started empty, and the only thing that ever filled it was my own code calling setQueryData when someone tapped a heart. I was using the cache as a global variable. React Query was only holding a value I wrote by hand.

The navbar count read from this list:

const wishlistCount = wishlistIds?.length ?? wishlistProducts?.length ?? 0;

After a page reload, wishlistIds was [], so its length was 0. The ?? operator only falls back when the left side is null or undefined, not when it is 0. So the count showed nothing until the user tapped a heart, even when their wishlist had items.

The next day I rewrote it so the query does its real job:

Second version
export function useWishlistProductIds(userId: string | undefined) {
  return useQuery({
    queryKey: ["wishlist", "productIds", userId],
    queryFn: () => fetchWishlistProductIds(userId),
    enabled: Boolean(userId),
    staleTime: 1000 * 60 * 5,
  });
}

First version

  1. Page loads, cache starts as []
  2. The server is never asked
  3. A heart tap writes an ID into the cache
  4. Reload: back to []

Second version

  1. Page loads, the query fetches the saved IDs
  2. Every heart and the navbar read that one query
  3. A heart tap updates the cache right away
  4. After 5 minutes it refetches to stay in sync

Every component now uses this one hook, a heart tap still updates the cache instantly, and the data comes from the server instead of from my memory of what the user clicked. The change also removed more code than it added: the commit deleted 85 more lines than it wrote.

The lesson: setQueryData is for updating data the server already gave you, not for inventing state the server never sent. If a value has no queryFn that fetches it, it probably belongs in normal state, not in React Query.

3. Tell the cache when data changes

When someone places an order, their order history is now out of date. React Query cannot know that on its own. If the order history was already in the cache, the user could finish checkout, open "My orders" and not see the order they just placed.

The fix is to mark that data as stale after a successful order:

useCompletePurchase
return useMutation({
  mutationFn: completePurchase,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["orderHistory"] });
  },
});

invalidateQueries matches every key that starts with ["orderHistory"], whatever page or filter it was for, and marks it as stale. A list that is on screen refetches right away; the others refetch the next time they are shown. I added the same call after the eSewa payment check, because that is a second way to finish an order.

This works because the team kept all query keys in one file, built from a shared root. That made it easy to invalidate a whole group of queries with one line.

What ties them together

All three mistakes came from treating React Query as a place to keep variables, when it is really a copy of what the server knows:

  • Keep one copy for the whole app (one client).
  • Fill it from the server (a real queryFn).
  • Tell it when the server's answer has changed (invalidate after a mutation).

None of these needed a new library or a big refactor. Each one was a small change once I understood what React Query was responsible for, and what it wasn't.