How to Use a Mock REST API for React Development (Without the Bugs Your Mock Is Too Fast to Show)

Mimicry

There's a class of React caching bug that only exists because the mock is too fast.

The bug looks like this: a search box that returns the previous query's results, but only sometimes, and only against the real backend. In development, against a mock that responds in 4 milliseconds, you cannot see it — the cache invalidates before you have time to notice it was ever wrong. The first time anyone sees the bug is in production, with users typing fast and a backend that takes 800 ms to answer.

The cause, nine times out of ten, is a queryKey that doesn't include the parameters that actually affect the request. It isn't React Query's fault. It isn't the backend's fault. It isn't really the mock's fault either — it's that the mock made the wrong code look correct.

That's the article in three paragraphs. React with a mock REST API is the easy part. Using React Query (or SWR, or any caching layer) correctly against a mock is where most production bugs sneak through. This post focuses on the React-specific patterns that make the difference — and for the workflow part (why mock at all, contract design, env vars) it'll point at How to Mock an API for Frontend Development instead of repeating it here.

The setup, briefly

If you've done this before, skim. If not, read once.

One env var for the base URL:

VITE_API_URL=https://mimicry.rest/m/my-shop

One typed fetcher per resource:

const API = import.meta.env.VITE_API_URL;

type User = {
  id: string;
  fullName: string;
  email: string;
};

type UsersResponse = {
  data: User[];
  meta: { page: number; limit: number; total: number; totalPages: number };
};

export async function getUsers(params: { page?: number } = {}): Promise<UsersResponse> {
  const url = new URL(`${API}/users`);
  if (params.page) url.searchParams.set("page", String(params.page));

  const res = await fetch(url);
  if (!res.ok) throw new Error(`Users request failed: ${res.status}`);
  return res.json();
}

That's it. The same code works against the mock today and against the real backend in three months — you change VITE_API_URL and nothing else. The fetcher is the boundary; everything React-specific lives above it.

(For the deeper version of this setup — JSON contract, sharing it with the backend team, why fetch instead of importHow to Mock an API for Frontend Development is the canonical piece.)

Query keys: where most React Query bugs live

This is the section that explains the bug from the opener — and why a fast mock hides it.

A query key is React Query's identity for a cached request. Two queries with the same key share a cache entry. Two queries with different keys don't. That sounds simple until you have parameters.

Wrong:

useQuery({
  queryKey: ["orders"],
  queryFn: () => getOrders({ search }),
});

Right:

useQuery({
  queryKey: ["orders", { search }],
  queryFn: () => getOrders({ search }),
});

The wrong version returns cached results from the previous search because the key never changes. The right version invalidates the cache automatically when search changes. The bug doesn't show against a 4 ms mock, because the refetch lands before you can perceive it. Against any real network, it's catastrophic.

The rule: every parameter that affects the request belongs in the query key. Filters, pagination, sort fields, the user ID if it scopes the query — all of them. React Query serializes the key with deep equality, so you can pass objects directly.

If you're worried about over-keying ("won't I have a million cache entries?"), that's what gcTime is for — unused query data is garbage-collected after 5 minutes by default. You can lower it. The cost of a few thousand cache entries that auto-evict is much lower than the cost of one bug that shows the wrong data to a real user.

Mutations and invalidation: POST without forgetting to refresh

Queries refresh automatically when their key changes. Mutations don't. This catches more frontend devs than it should.

When you POST a new order, React Query has no idea that your ["orders"] query is now stale. It will happily keep showing the old list until something forces a refetch — most likely the user clicking the refresh button you forgot to add. The fix is one line:

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

function CreateOrderButton() {
  const queryClient = useQueryClient();

  const createOrder = useMutation({
    mutationFn: (order: NewOrder) => postOrder(order),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["orders"] });
    },
  });

  return (
    <button onClick={() => createOrder.mutate({ total: 49.9, status: "pending" })} disabled={createOrder.isPending}>
      {createOrder.isPending ? "Creating..." : "Create order"}
    </button>
  );
}

invalidateQueries({ queryKey: ["orders"] }) marks every query whose key starts with ["orders"] as stale and refetches the ones currently rendered. So ["orders", { search: "foo" }] and ["orders", { page: 2 }] get refreshed too, because the prefix matches. That's the feature that makes the keying strategy from the previous section pay off — one invalidate, every variant refreshes.

Optimistic updates are the level up, and they're tempting but tricky. The pattern is "write the new value into the cache immediately, roll back if the server fails":

const createOrder = useMutation({
  mutationFn: postOrder,
  onMutate: async (newOrder) => {
    await queryClient.cancelQueries({ queryKey: ["orders"] });
    const previous = queryClient.getQueryData(["orders"]);
    queryClient.setQueryData(["orders"], (old: UsersResponse) => ({
      ...old,
      data: [{ ...newOrder, id: "temp-" + Date.now() }, ...old.data],
    }));
    return { previous };
  },
  onError: (_err, _newOrder, context) => {
    if (context?.previous) {
      queryClient.setQueryData(["orders"], context.previous);
    }
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ["orders"] });
  },
});

Worth it for like-button or favorite-toggle interactions where the user expects zero latency. Probably not worth it for "place order", because the user is fine with a 1-second spinner if the alternative is a flicker-of-acceptance followed by a flicker-of-failure. Pick the pattern based on what a wrong-then-rollback looks like to the user, not on whether it's technically possible.

Dependent queries: when one fetch needs the result of another

A common shape: load the current user, then load their orders. The naive version:

const userQuery = useQuery({
  queryKey: ["user", "me"],
  queryFn: getCurrentUser,
});

const ordersQuery = useQuery({
  queryKey: ["orders", { userId: userQuery.data?.id }],
  queryFn: () => getOrders({ userId: userQuery.data!.id }),
});

This fires both queries on mount. The orders query immediately fails because userQuery.data is undefined. You see a second of spinning errors before the user resolves. Not the look you want.

Fix:

const ordersQuery = useQuery({
  queryKey: ["orders", { userId: userQuery.data?.id }],
  queryFn: () => getOrders({ userId: userQuery.data!.id }),
  enabled: !!userQuery.data?.id,
});

The enabled flag stops the second query from firing until the dependency is ready. The query enters a "pending" state that isn't a real fetch — ordersQuery.isLoading is true even though nothing is on the wire. Most teams handle this by checking userQuery.isLoading || ordersQuery.isLoading together.

Against a slow mock this is the place where you see "the loading skeleton runs for both queries even though one hasn't started yet". Tune your skeleton accordingly — it should look the same regardless of which query is currently pending.

Retry and staleTime: the defaults that bite

React Query's defaults are sensible but specific:

  • retry: failed queries retry 3 times with exponential backoff. A 500 takes about 3-4 seconds of spinning before the user sees the error UI. Set retry: 1 (or 0 for form submissions) when the failure is the user's signal to do something, not noise to ignore.
  • staleTime: 0 by default. Every query is immediately stale and refetches on window focus, network reconnect, and component remount. Fine for most CRUD lists. For data you know rarely changes (current user profile, list of countries, feature flags), set staleTime: Infinity or a few minutes.
  • gcTime (was cacheTime in v4): 5 minutes. Unused query data is kept in memory for that long after the last component using it unmounts. Lower it to free memory faster; raise it to make navigation feel instant.

Against a chaos-enabled mock you'll discover whether these defaults match your UX. A retry: 3 against an 8% failure rate means the user spends 3-4 seconds before seeing 92% of failures (the other 8% pass the retry quietly). Sometimes that's the right tradeoff. Sometimes it's eating the UX in silence.

Error handling for mutations

useMutation's onError doesn't propagate to React error boundaries by default. The mutation just goes into an isError state and the component handles it inline. That's correct — error boundaries are for unexpected exceptions, not "the POST failed, show the user a toast".

The pattern that works:

const createOrder = useMutation({
  mutationFn: postOrder,
  onError: (error) => {
    toast.error(`Could not create order: ${error.message}`);
  },
  onSuccess: () => {
    toast.success("Order created");
    queryClient.invalidateQueries({ queryKey: ["orders"] });
  },
});

The trap: if you also render an error message from createOrder.error somewhere in the JSX, the user sees both the toast and the inline message. Pick one — the toast for transient errors the user retries, the inline render for errors the user needs to resolve before continuing (validation, conflicting state, "the order total doesn't match the cart").

Testing the loading states a fast mock hides

Once the React Query setup is in place, you still have to verify it works under conditions a 4 ms mock can't reproduce. The two most common bugs that mocks hide:

  • The skeleton flashes for one frame and disappears. Looks fine in dev because the mock is 4 ms. Looks broken in
  • production because the skeleton component has alignment bugs you can't see at 4 ms but you can see at 800 ms.
  • The mutation hangs forever. Your disabled={createOrder.isPending} works only if the mutation eventually resolves.
  • If the backend is unreachable, isPending stays true and the button never re-enables. Most teams discover this in production.

The fix is to make the mock slow and unreliable enough to surface these. In Mimicry, configure Chaos Mode on the relevant endpoint:

  • 1-3 second random latency on GET (the skeleton path)
  • 30% failure rate on POST (the mutation error path)
  • A latency spike at 5% chance to 5+ seconds (the "is this stuck?" path)

Develop against that for an hour. Every component handles the four UI states honestly. The full UI-state checklist is in How to Test Loading, Empty, Error, and Success States; the chaos workflow itself is in API Chaos Testing for Frontend Developers.

A worked example: orders dashboard

All of the above, in one realistic component:

import { useState } from "react";

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

const API = import.meta.env.VITE_API_URL;

async function getOrders(params: { userId: string; page: number }) {
  const url = new URL(`${API}/users/${params.userId}/orders`);
  url.searchParams.set("page", String(params.page));
  url.searchParams.set("limit", "20");
  url.searchParams.set("sort", "-createdAt");
  url.searchParams.set("_embed", "order-items");

  const res = await fetch(url);
  if (!res.ok) throw new Error("Could not load orders");
  return res.json();
}

async function postOrder(data: { userId: string; total: number }) {
  const res = await fetch(`${API}/orders`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  if (!res.ok) throw new Error("Could not create order");
  return res.json();
}

export function OrdersDashboard({ userId }: { userId: string }) {
  const queryClient = useQueryClient();
  const [page, setPage] = useState(1);

  const ordersQuery = useQuery({
    queryKey: ["orders", { userId, page }],
    queryFn: () => getOrders({ userId, page }),
    staleTime: 30_000,
    retry: 1,
  });

  const createOrder = useMutation({
    mutationFn: postOrder,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["orders"] });
      toast.success("Order created");
    },
    onError: (err) => toast.error(err.message),
  });

  if (ordersQuery.isLoading) return <OrdersSkeleton />;
  if (ordersQuery.isError) return <ErrorBanner onRetry={ordersQuery.refetch} />;
  if (!ordersQuery.data.data.length) return <EmptyState />;

  return (
    <>
      <OrderList orders={ordersQuery.data.data} />
      <Pagination page={page} totalPages={ordersQuery.data.meta.totalPages} onChange={setPage} />
      <button disabled={createOrder.isPending} onClick={() => createOrder.mutate({ userId, total: 49.9 })}>
        New order
      </button>
    </>
  );
}

Five things this gets right that most "React Query tutorial" code doesn't:

  • Query key includes { userId, page } — both parameters that affect the request, so the cache invalidates correctly when either changes
  • staleTime: 30_000 because order lists don't need to refetch on every focus event
  • retry: 1 because if the read rejects, three more attempts just delay the inevitable error
  • invalidateQueries after the mutation, so the list refreshes without a full reload
  • Inline error UI with onRetry that calls refetch directly — no full-page reload, the failed query just runs again

That's roughly the level of resilience you want before pointing VITE_API_URL at staging.

Where to go from here

This article is the React-specific deep dive. The companion pieces:

You can spin up a mock that matches your React app's shape in about a minute at Mimicry — guest mode is enough for a single endpoint, and registration unlocks the multi-resource templates that map naturally onto a real React data layer.

Ready to try it yourself?

Stop waiting for the backend. Use Mimicry to create a mock API, launch a mock REST API generator, or share a hosted mock API with your team.

Create a Mock API

BUILDING ARCHITECTURE