The app talks to its server through one typed ApiClient port whose only transport calls fetch with
a deadline, checks every response against a valibot schema and never rejects; server data lives in
TanStack Query's cache, defined once per resource in src/api/, read in model hooks, retried only when
a retry can help, kept fresh by React Native focus and connectivity, and honest about offline. A
zero-dependency checker proves it.
Rules that must hold
- One network seam.
fetch,XMLHttpRequest,WebSocket,EventSourceandsendBeaconappear only insrc/api/, andfetchin exactly one file,src/api/fetch-api-client.ts; no HTTP client package (axios, ky,expo/fetchimports).src/api/api-client.tsexportsApiClient,src/api/api-failure.tsexportsApiFailure. Why: one place owns deadlines, validation, auth headers and the privacy inventory, and lint can ban the rest. - The ApiClient never rejects.
request()returnsResult<T, ApiFailure>: offline, timeouts, cancellation, HTTP errors and invalid bodies are typed values; every awaitedfetchand body read sits inside onetry/catch; a failure is classified by who aborted (the caller's signal, the client's own deadline) and by status, never by error text. Why: expo/fetch, React Native's fetch and Node's fetch word every error differently, and a rejection that escapes crashes a release handler. - Every attempt has a deadline and honours the caller's signal. The transport aborts each attempt
after
timeoutMs(default 15 s, body included), links TanStack'ssignal, and everyqueryFnpassessignalto the request. Why: Android's OkHttp client has no timeouts, so a stalled request never ends; TanStack cancels an abandoned fetch only when thequeryFnread the signal. - Every response passes its schema, tolerantly. Each request names a valibot
schema; the transportsafeParses every 2xx body and records an invalid one once (method, path, issue paths, never the body); no.json() as T, no.json()orJSON.parseoutside the transport; response schemas usev.object(neverv.strictObject) and wrap closed sets inv.fallback. Why: a cast checks nothing, and a server that adds a field or an enum value must not break installed apps. - The base URL comes from config, over HTTPS.
EXPO_PUBLIC_API_URLper EAS environment, read by the app'ssrc/configreader and checked byparseApiBaseUrl(https outside development); no URL literal insrc/api/, nobaseUrl: '<literal>', noNSAllowsArbitraryLoadsorusesCleartextTrafficswitch. Why: each variant talks to its own server, and release builds block cleartext on iOS (ATS) and Android 9+, so a global switch ships to the store. - Queries and mutations are defined once, next to their keys. Each resource has
src/api/<resource>/<resource>-keys.ts(hierarchical,allroot,as const),-schemas.ts,-queries.ts(queryOptions/infiniteQueryOptions) and-mutations.ts(mutationOptions); no inline key arrays, noqueryFn/mutationFnor.request({ method, path })elsewhere. Why: one typo in an inline key misses an invalidation, andqueryOptionsties the key to the data type forgetQueryData/setQueryDatawithout casts. - Server state is read in the feature's model hook. TanStack hooks appear only in
src/features/**/use-*.ts(x),src/hooks/andsrc/api/; views get plain props; query data is never copied into a store,useStateor an effect (the house ESLint config catches the effect copy, not this checker). Why: two copies drift on every refetch, and the view stays testable without a client. - One QueryClient per app run, with a failure-aware retry policy.
createQueryClientinsrc/api/is the onlynew QueryClientin app code, called once from AppRoot'sstartApp(auseStateinitializer, after the data layer and the error sink; never at module level or in a render body); queries retry only retryable failures (no connection, deadline, 408, 429 afterRetry-After, 5xx), at most 3 times; mutations retry only with anidempotencyKey; neverretry: true/Infinity/more than 3. Why: TanStack's default spins about seven seconds on a 404, and a retried POST without a key can create two tasks. - Focus and online come from React Native, through one port.
connectQueryManagersfeedsfocusManagerfrom AppState (activeonly) andonlineManagerfrom theConnectivityPort, whose one expo-network adapter (the only importer of expo-network or NetInfo) is created by the composition root;QueryProvidermounts the client and the wiring in the root providers. Online meansisConnectedalone; Android'sisInternetReachableis only thereachability()hint for banners, ads and purchases. Why: React Native has no window events, so without it nothing pauses offline, resumes on reconnect or refetches on return; and Android marks some working networks unvalidated for good, where pausing on reachability would keep every request waiting forever. - Every write says what it does to the cache; optimistic writes can roll back. A mutation
invalidates or sets the keys it changed in
onSuccess/onSettled, or carries// no-cache-effect: <why>; anonMutatethat writes the cache cancels in-flight queries first, snapshots, restores the snapshot inonErrorand invalidates inonSettled. Why: otherwise lists show old data, or a refused write stays on screen as if it happened. - Offline pauses, it does not fail. Keep
networkMode: 'online'(a local-only query says// network-mode-always: <why>); show cached data with an offline notice, never an error in its place; a persisted cache setsbusterandmaxAge; writes that must survive a kill go to the outbox (persistence-and-migrations) and sync with their id asidempotencyKey. Why: paused work resumes by itself, and a restored cache in an old shape would skip every schema. - Tests use the seams, never the network. The transport gets a fake
FetchLike; everything above it getscreateFakeApiClient()fromtest/api/, which checks canned bodies through the request's schema and rejects unscripted requests; no test replaces the globalfetchor mocksexpo/fetch. Why: a replaced global leaks across tests and proves a seam the app never uses. - The UI shows failures by kind. Model hooks read
apiFailureOf(error)and map eachkindthrough oneRecord<ApiFailureKind, MessageKey | null>table; nevererror.message;cancelledis never shown and expected failures are never recorded. Why: a new kind is then a compile error until it has a message in every locale.
Workflow
- Decide whether the app has a server. An app with no network at all follows the
offline-onlyprofile of privacy-and-store-declarations instead (the checker prints NOT APPLICABLE there). Read references/api-client-and-transport.md. - Install (stack table, verified 2026-10-03):
npx expo install expo-networkandnpm install -E @tanstack/react-query@5.104.0 valibot@1.5.0. - Copy
templates/src/api/(withtasks/),templates/src/config/api-base-url.tsand its test,templates/src/services/connectivity/, andtemplates/test/api/. The synced files (src/core/result.ts,src/config/app-variant.ts,src/services/error-log/*,test/flush-microtasks.ts) come from their owner skills: copy them only if the app lacks them. - Environment: declare
EXPO_PUBLIC_API_URLinsrc/env.d.tsand read it in the app'ssrc/config/environment.tsasexamples/environment.tsshows. Setting the value in each EAS environment (eas env:set --name EXPO_PUBLIC_API_URL --value <https-url> --environment <env> --visibility plaintext) is the developer's step: stop and ask before running it. - Composition root: add
apiandconnectivitytoServices,createDeviceServicesandcreateTestServicesas inexamples/composition-root/. - Startup: read the checked environment in the root layout's
decideStartup(examples/root-layout.tsx), create the client in AppRoot'sstartAppright after the error sink, and renderQueryProviderright afterAppDataProviderinsideAppProviders(examples/app-root.tsx,examples/app-providers.tsx; app-startup-and-bootstrap owns the order). - Each resource: replace the Tasks keys, schemas, queries and mutations with the app's own, reading
references/schemas-and-failures.md,
references/queries-and-cache.md and
references/mutations-and-optimistic-updates.md.
Paged lists:
examples/task-pages.ts. - Model hooks: read server state as
examples/use-tasks-model.tsdoes, turn query state into a view state (examples/task-list-state.ts) and failures into message keys (examples/api-failure-message.ts; add the keys to every catalog with i18n-strings-and-catalogs). - Offline needs (a persisted cache, an outbox, sign-out clearing): read references/online-focus-and-offline.md.
- Tests: follow references/testing-server-state.md; run
npx jest src/api src/config src/services/connectivity test/api --ciandnpm run typecheck. - Run
node ${CLAUDE_SKILL_DIR}/scripts/check-server-state.mjs .from the repo root; fix eachFAILline (rule ids in references/checker-rules.md) and rerun untilRESULT: PASS.
Definition of done
-
fetchappears only insrc/api/fetch-api-client.ts; no other network API outsidesrc/api/; no HTTP client package. -
request()never rejects: fetch and the body read sit in onetry/catch; each attempt has a deadline linked to the caller's signal; everyqueryFnpassessignal. - Every request names a schema; invalid bodies are recorded once without the data; response schemas are tolerant (
v.object,v.fallbackaround closed sets). - The base URL comes from
parseApiBaseUrl(process.env.EXPO_PUBLIC_API_URL, variant)insrc/config; each EAS environment has its HTTPS value (or the report says the developer still has to set it); no cleartext switch. - Each resource has keys, schemas,
queryOptionsandmutationOptionsfactories insrc/api/<resource>/, each with its test against the fake ApiClient. - TanStack hooks only in model hooks; one client from
createQueryClientin AppRoot'sstartApp(auseStateinitializer);QueryProvidermounted right afterAppDataProvider. - Every mutation has a cache effect (or a reasoned
no-cache-effectmarker); optimistic ones cancel, snapshot, roll back and invalidate; retrying writes send an idempotency key. -
npx jest src/api src/config src/services/connectivity test/api --cipasses andnpm run typecheckis clean; the device checks of testing-server-state.md section 9 ran, or the report says they are owed. -
node ${CLAUDE_SKILL_DIR}/scripts/check-server-state.mjs .printsRESULT: PASS. Expected only before the root layout exists:SKIP src/app/_layout.tsx [managers-wired] not yet created (navigation-and-routing creates it). An app without a server printsNOT APPLICABLE: no app code imports @tanstack/react-query or calls the network, and there is no src/api/.
Anti-patterns
fetchin a hook or a screen "just for this one call". No deadline, no schema, no auth header, no fake in tests; add a query factory insrc/api/<resource>/.await response.json() as Task[]. Nothing is checked; the first server change crashes a screen far from the cause. Pass a schema.- Deciding offline from
error.message === 'Network request failed'. That text exists only in React Native's fetch; expo/fetch saysfetch failed: .... Ask the signal, the deadline flag and the ConnectivityPort. new QueryClient()at module level or in the layout body. It outlives logout and is shared by every test, or it is a new empty cache on every render. Create it in auseStateinitializer.retry: 3on a mutation, or keeping TanStack's default retry for queries. Duplicate writes; seven seconds of spinner before a 404 shows.queryKey: ['task', id]typed inline. One letter off from['tasks'], and the invalidation misses it. Use the key factory.- Copying
query.datainto a Zustand store oruseState"to edit it". Two copies drift; edit a draft keyed by id, and write through a mutation. - NetInfo or
expo-networksubscribed in a screen or in the onlineManager wiring. A second native subscription, and on iOS the module's single monitor can be cancelled for good. Read the ConnectivityPort. - Feeding onlineManager from
isInternetReachable. On an Android network that never validates (a firewall blocking the probe, "use this network anyway") every query stays paused forever while requests would succeed. Feed itisOnline(); showreachability()in a banner. - An optimistic update without
cancelQueriesor a rollback. The value flickers back, or a refused write stays on screen. usesCleartextTraffic: trueto make a preview build reachhttp://10.0.2.2. It ships to the store; give the API an HTTPS endpoint per environment.jest.spyOn(globalThis, 'fetch'). Leaks into other tests; pass a fakeFetchLikeor the fake ApiClient.- Persisting the query cache without
busterandmaxAge. Restored data in an old shape skips every schema, and data of any age reappears.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/api-client-and-transport.md | The port, what fetch is on iOS and Android in SDK 57, deadlines, cancellation, failure classification, URLs and headers, idempotency, base URL and cleartext per platform, HTTP cache and cookies, verified facts |
Workflow steps 1-4 |
| references/schemas-and-failures.md | One schema per response, tolerant reading, transforms, recording invalid responses, the failure family and who handles each kind, unwrap/apiFailureOf |
Workflow steps 7-8 |
| references/queries-and-cache.md | TanStack defaults, queryOptions, key factories, the retry policy, freshness, stacked screens and subscribed, status vs fetchStatus, dependent, paged and prefetched queries |
Workflow steps 7-8 |
| references/mutations-and-optimistic-updates.md | mutationOptions, choosing a cache effect, the four optimistic steps, UI-only optimism, concurrent writes, idempotency, conflicts, calling mutations, 5.104 callback signatures |
Workflow step 7 |
| references/online-focus-and-offline.md | Why React Native needs manager wiring, the ConnectivityPort on iOS and Android, AppState focus, paused queries and mutations, offline UI, the persisted cache, the outbox split, sign-out | Workflow steps 6 and 9 |
| references/testing-server-state.md | The seam per layer, transport, factory, hook, manager and optimistic tests, timing without fake timers, device checks | Workflow step 10 |
| references/checker-rules.md | Every rule id, its scope, the two markers, layout keys, monorepo use, known limits | Workflow step 11, on a FAIL |
templates/src/api/ |
api-client.ts, api-failure.ts, api-error.ts, build-url.ts, to-api-failure.ts, fetch-api-client.ts, api-query-options.ts, create-query-client.ts, query-managers.ts, query-provider.tsx, and their tests; tasks/ holds the Tasks resource (keys, schemas, queries, mutations with an optimistic toggle and an idempotent create, with tests) |
Workflow steps 3 and 7 |
templates/src/config/ |
api-base-url.ts (parseApiBaseUrl, the checked base URL per variant) and its test; app-variant.ts and its test are synced from app-variants-and-environment (do not edit here) |
Workflow steps 3-4 |
templates/src/services/connectivity/ |
The ConnectivityPort (isOnline plus the reachability hint), its expo-network adapter, the shared reachability-hub.ts fan-out and the fake, with tests (this skill owns them; synced from the library, do not edit here) |
Workflow step 3 |
templates/src/services/error-log/ |
The error log port and fake with its test, synced from architecture-and-boundaries (do not edit here) | Workflow step 3, only if the app lacks them |
templates/src/core/ |
result.ts and its test, synced from error-handling-typed-results (do not edit here) |
Workflow step 3, only if the app lacks them |
templates/test/ |
api/fake-api-client.ts, the scripted ApiClient for tests, and its test; flush-microtasks.ts, synced from unit-and-component-tests (do not edit here) |
Workflow steps 3 and 10 |
examples/environment.ts |
The app's public-environment reader with the API base URL | Workflow step 4 |
examples/composition-root/ |
Services, createDeviceServices, createTestServices with the api and connectivity ports, and a test |
Workflow step 5 |
examples/root-layout.tsx |
The canonical root layout reading the checked environment in decideStartup |
Workflow step 6 |
examples/app-root.tsx |
The canonical AppRoot creating the QueryClient once in startApp |
Workflow step 6 |
examples/app-providers.tsx |
AppRoot's outer providers with QueryProvider right after AppDataProvider |
Workflow step 6 |
examples/task-pages.ts |
A paged list with infiniteQueryOptions and a cursor |
Workflow step 7 |
examples/use-tasks-model.ts |
A model hook over server state (subscribed: isFocused, an optimistic toggle) |
Workflow step 8 |
examples/use-tasks-model.test.tsx |
Testing that hook in a route with the fake ApiClient, offline included | Workflow steps 8 and 10 |
examples/task-list-state.ts |
TanStack's status and fetchStatus turned into one view state | Workflow step 8 |
examples/api-failure-message.ts |
The message table of the ApiFailure family, with the catalog strings | Workflow step 8 |
scripts/check-server-state.mjs |
The checker (21 rules; options --src-dir, --api-dir, --json) |
Workflow step 11, and at the end |
scripts/lib/ |
The checker's rule sets and source model, and the synced source-scan.mjs (do not edit that one here) |
Never by hand |
scripts/check-lib.mjs |
Shared script helper, synced from the library (do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves the checker passes the templates and examples and catches every planted bug | After changing the checker, a template or an example |
assets/shared.json |
Declares the shared files this skill copies in | When adding a shared file |
tests/ |
The overlay base app (with planted findings in installed-skill folders that must stay silent) and one fixture per rule, plus pass and error cases | When adding a checker rule |
Related skills
error-handling-typed-results- the Result type, failure families, the error net; the ApiClient is one of its boundaries.architecture-and-boundaries- ports, adapters and the composition root the ApiClient and ConnectivityPort join.app-variants-and-environment-EXPO_PUBLIC_*readers and EAS environments.state-management-zustand- client state; no server data in stores (itsserver-state-in-storecheck).persistence-and-migrations- local tables, the outbox and the kv-store adapter under a persisted cache.auth-and-secure-storage- tokens, theheaders()hook and sign-out.async-safety- effects, listeners and cancellation outside TanStack Query.ui-states-and-feedback- loading, offline and error states on screen.forms-and-input- server validation codes mapped to fields.unit-and-component-tests- the test app, render helpers and test forms.privacy-and-store-declarations- the network inventory and theoffline-onlyprofile.admob-ads-and-consent,in-app-purchases- other consumers of the ConnectivityPort.background-tasks- syncing the outbox while the app is closed.- Expo's official
expo-data-fetchingskill - the Expo fetch API reference.