Building It
The folder layout, the file-based routes, a typed API layer and a query cache that survives a cold start with no network.
useQuery({ queryKey: entryKeys.entry(id), queryFn: () => getEntry(id) })The app has two top-level folders, and the split carries weight. app/ holds routes only. Every file in it becomes a navigable screen, so a helper parked there turns into a route by accident. Everything else lives under src/: api/, domain/, features/, ui/. A file in app/ stays thin. It reads its route parameters and renders a feature.
Routing
A folder in parentheses, (tabs), groups routes without adding a path segment. A _layout.tsx declares the navigator for its folder. The root layout is the only place that runs once for the whole app, which is why the providers go there and nowhere else.
The API layer
One client module owns the base URL, the auth header and error translation. One module per resource exports plain async functions above it. Screens never call fetch. That single rule is what makes the screens testable later, because a test can replace one module instead of the network.
Caching and offline
React Query holds server data. staleTime decides when a refetch happens; gcTime decides how long data stays available, including on disk. PersistQueryClientProvider writes the cache through AsyncStorage, so a cold start with no network draws the last feed instead of a spinner. One detail is native-specific: React Query learns about the network from the browser's online event, which does not exist here, so NetInfo has to tell onlineManager instead.
What trips people up
Query keys are cache identity. Build them in one factory. Two screens hitting the same endpoint with different arguments must put those arguments in the key, or the second shows the first one's data.
Example
// app/_layout.tsx
// On screen: nothing of its own. This file wraps every route in the query
// cache and declares the stack, so the first thing drawn is sign-in or
// the tabs, with no flash of an empty screen on a cold start.
import AsyncStorage from "@react-native-async-storage/async-storage";
import NetInfo from "@react-native-community/netinfo";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { QueryClient, onlineManager } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { Stack } from "expo-router";
// There is no browser online event on a device, so NetInfo reports the
// network instead. Without this, a reconnect does not trigger a refetch.
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(!!state.isConnected))
);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // refetch after half a minute of being looked at
gcTime: 1000 * 60 * 60 * 24, // keep it for a day, cold starts included
retry: 2,
},
},
});
const persister = createAsyncStoragePersister({ storage: AsyncStorage });
export default function RootLayout() {
return (
<PersistQueryClientProvider
client={queryClient}
// { persister } is shorthand for { persister: persister }.
persistOptions={{ persister }}
>
<Stack>
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="entry/[id]" options={{ title: "Entry" }} />
<Stack.Screen name="capture" options={{ presentation: "modal" }} />
</Stack>
</PersistQueryClientProvider>
);
}When to use it
- Laying out a new app so routes, domain rules and API calls do not grow into each other.
- Making a feed readable on launch with the network off, from the cache written on the last run.
- Giving every screen the same three states (pending, error, data) without repeating the plumbing.
More examples
One client for every request
Every request in the app goes through this function, which is why the base URL, the auth header, and the translation of a bad status into a typed error each appear exactly once. The Content-Type guard is the detail people lose a day to: setting it on a multipart upload breaks the boundary and the server rejects the file.
// src/api/client.ts
// EXPO_PUBLIC_ variables are inlined into the bundle at build time, so
// this is the only file that knows the base URL.
const BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "https://api.example.com";
// ?? is shorthand for: EXPO_PUBLIC_API_URL == null ? fallback : the value.
export class ApiError extends Error {
constructor(message: string, readonly status: number) {
super(message);
}
}
let accessToken: string | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
// Only JSON bodies get this header. A FormData body must be left
// alone so fetch can add its own multipart boundary.
...(typeof init.body === "string" ? { "Content-Type": "application/json" } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
...init.headers,
},
});
if (!response.ok) {
throw new ApiError(`${init.method ?? "GET"} ${path} failed`, response.status);
}
return (await response.json()) as T;
}A resource module with a key factory
The functions and the hooks that wrap them sit together, one module per resource. Exporting the raw functions as well as the hooks is what lets a test stub the network while leaving the caching behaviour real. getNextPageParam returning null is how the list knows it has reached the end.
// src/api/entries.ts
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import type { Entry, Feed } from "../domain/entry";
import { request } from "./client";
// Keys in one place, so no two screens can disagree about them.
export const entryKeys = {
feed: ["feed"] as const,
entry: (id: string) => ["entry", id] as const,
};
// An arrow function body without braces returns its expression. The
// longhand is: function getFeed(cursor) { return request(...); }
export const getFeed = (cursor: string | null) =>
request<Feed>(`/entries?cursor=${cursor ?? ""}`);
export const getEntry = (id: string) => request<Entry>(`/entries/${id}`);
export async function uploadPhoto(uri: string): Promise<Entry> {
const form = new FormData();
// This three-field object is how a local file is attached on a device.
// There is no File constructor and no input element here.
form.append("photo", { uri, name: "entry.jpg", type: "image/jpeg" } as unknown as Blob);
return request<Entry>("/entries/photo", { method: "POST", body: form });
}
export function useFeed() {
return useInfiniteQuery({
queryKey: entryKeys.feed,
queryFn: ({ pageParam }) => getFeed(pageParam),
// Both of these are required; there is no implicit first page.
initialPageParam: null as string | null,
getNextPageParam: (last: Feed) => last.nextCursor,
});
}
export function useEntry(id: string) {
return useQuery({ queryKey: entryKeys.entry(id), queryFn: () => getEntry(id) });
}A route file that stays thin
This is the whole of a route file: read the parameter, call the hook, render three states. Anything larger belongs in src/features, where it can be imported by a test without the router. Note that the loading and error branches come before the data branch, and that no hook is called after them.
// app/entry/[id].tsx
// On screen: a spinner, then a bold title with the body text beneath it,
// or one line of failure text. Nothing else lives in a route file.
import { useLocalSearchParams } from "expo-router";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { useEntry } from "../../src/api/entries";
export default function EntryDetail() {
// The name comes from the filename: [id] means a parameter called id.
// Route parameters are always strings.
const { id } = useLocalSearchParams<{ id: string }>();
// isPending is the name for a query with no data yet.
const { data, isPending, isError } = useEntry(id);
if (isPending) return <ActivityIndicator style={styles.pad} />;
if (isError || !data) {
// A bare string here would throw. Text must wrap it.
return <Text style={styles.pad}>This entry could not be loaded.</Text>;
}
return (
<View style={styles.pad}>
<Text style={styles.title}>{data.title}</Text>
<Text>{data.body}</Text>
</View>
);
}
const styles = StyleSheet.create({
pad: { padding: 16, gap: 8 },
// fontWeight does not inherit to a nested View's Text children. Only
// Text passes text styles down, and only to Text inside it.
title: { fontSize: 20, fontWeight: "600" },
});
Discussion