ParkEase has two React apps talking to the same API: an Android app (Expo, React Native) for drivers, owners, valets and car washers, and an admin panel (React and Vite). Both get every byte of server data through TanStack Query. Neither has Redux, Zustand or a hand-rolled fetch-into-useState anywhere.

That was a rule, not a preference. Here's why it was the right one, and how differently the two apps use the same library.

The rule: server state is not app state

The mobile rules say it in one line: TanStack Query for server state, never Redux, never Zustand. A booking, a list of parking spots, an earnings summary: these belong to the server. The app holds a cached copy, and the question is only how fresh that copy is and what to do when it's wrong.

Putting that copy in a global store means rebuilding caching, deduping, retries, refetching and invalidation yourself, badly. Leaving it in TanStack Query means one rule: read it straight from the cache, never copy it into useState. ParkEase's search hook says exactly that:

/** * Server state for discovery. Results live in the query cache and are read * straight from it — never copied into useState (R-FE-02), which is also why * the map/list toggle can swap renderers without refetching. */ 

The map view and the list view are two renderers of one cache entry. Switching between them costs nothing.

Mobile: retry only what a retry can fix

The default is to retry failed queries three times. That's wrong for half of all failures. A 403 won't become a 200 on the third attempt. And a response the app can't parse, because it's an old build meeting a new API, will be just as unparseable twice more, leaving a loading skeleton on screen for nothing.

So the query client retries through one shared failure classifier:

export function shouldRetryQuery(failureCount: number, error: unknown): boolean { if (failureCount >= MAX_QUERY_RETRIES) return false; return classifyFailure(error) === 'unreachable'; } 

Network failure, a 5xx, a timeout or a rate limit: retry. A definite 4xx or an unreadable body: show the right error immediately.

Mobile: offline, using TanStack's own machinery

Parking happens in basements. The app had to work with no signal, and all of it is built from TanStack Query's parts rather than beside them:

  • Online state. The phone's network status feeds TanStack's onlineManager, so queries pause instead of failing while offline, and refetch on reconnect.
  • A persisted cache. A few query families (your bookings, notifications, owner and partner earnings) are saved to storage and drawn from it on a cold start with no network. Saved data older than a day isn't shown: an old screen is more misleading than an empty one.
  • Queued actions. A mutation made offline pauses and replays on reconnect, in the order it was made, carrying the idempotency key minted when you tapped, so a replay the server already applied returns its stored result instead of acting twice.

And one deliberate exception. Search results, meaning which slots are free right now, are never cached, persisted or reused:

// Availability is never cached — a stale free-slot count is a double // booking or a lost sale (rules.md R-PERF-05). Overrides the 30s default. staleTime: 0, 

A library with good defaults still needs you to know which data must never be stale.

Admin: two helpers and nothing else

The admin panel needs none of that. It's a desktop tool on a reliable connection, used by a few operators. So the whole integration is two small hooks:

export function useApi<S extends z.ZodTypeAny>(schema: S, path: string, enabled = true) { return useQuery({ queryKey: [path], queryFn: () => request(schema, path), enabled }); } 

useApi fetches a path and parses the response with its Zod schema; the path is the cache key. useAction wraps a mutation, mints an idempotency key per click, and on success invalidates every query. Blunt, and right for an admin tool: invalidating everything is cheap at this scale and guarantees an operator never sees a stale approval queue after acting on it.

Almost every admin page goes through those two functions. The two exceptions are the long, paged lists, the audit log and the ledger explorer, which use useInfiniteQuery directly to load the next page on demand.

Why it was good

  • One mental model across both apps, even though they use it at opposite ends of the complexity scale.
  • Hard problems became configuration. Offline, ordered replay and selective persistence came from options and documented APIs, not custom infrastructure.
  • It suits AI-written code. An agent writing fetch-into-useState by hand produces subtly different loading, error and retry behaviour on every screen. With one query client and two rules, every screen behaves the same way, and a test can check that every data screen has a loading, an error and an empty state.

Next: the other half of the admin panel. Type-safe routing with TanStack Router, where a URL parameter is validated like an API request.

ParkEase is a peer-to-peer parking marketplace for India, built solo with Claude Code.
Code: https://github.com/Deonkar/parkease