About six months ago I started getting a run of two-star reviews that all said roughly the same thing: open the app on the subway platform and it sits there blank. I reproduced it in a low-signal spot, and sure enough the home screen stayed empty with a spinner turning forever.
I had already built "loading" and "retry" error screens for flaky networks. But the people leaving those reviews were not asking for a nicer error screen. They wanted one thing: "show me what I saw yesterday, right now."
An error screen is a way to report failure politely. Offline-first is a way to avoid showing failure at all. A Rork-generated app assembles its screens around server responses by default, so when the connection drops there is nothing to draw. What I needed was a layer that paints last session's data on launch and never throws away an action taken offline.
This is a record of retrofitting TanStack Query v5 persistence and an offline write queue into a Rork (React Native) Expo project. As an indie developer running several apps, I want to focus on how to make this work under two specific constraints: it is a retrofit, and the AI regenerates the project.
Paint the last data before you polish error screens
A note on priorities first. "Offline support" usually starts with retry buttons and connectivity banners, but the change that moved the needle most was removing the blank screen on launch.
If the generated code already uses TanStack Query, it fetches with useQuery. By default that cache lives only in memory and is gone when the app closes, so the next cold start refetches from scratch, which means a blank screen when there is no signal.
Persistence is what fixes this. Write fetched data to storage, restore it into memory on launch, and even offline the user immediately sees "last time's content." Most of those reviews went quiet on this single change.
You use @tanstack/react-query-persist-client, and on Expo you can persist to @react-native-async-storage/async-storage.
// guarded/offline/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // in-memory cache lifetime (was cacheTime)
staleTime: 1000 * 60 * 5, // do not refetch for 5 minutes
retry: 2,
},
},
});
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: "RORK_RQ_CACHE_V1", // bump the suffix to drop the cache on schema change
throttleTime: 1000, // throttle writes to once per second
});
export function setupPersistence() {
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // do not restore persisted cache older than 24h
dehydrateOptions: {
// only persist successful queries; never bake in an error state
shouldDehydrateQuery: (q) => q.state.status === "success",
},
});
}I set maxAge to 24 hours because my baseline is data like wallpapers or articles: fine to be a little stale, but awkward if it is more than a day old. For data where freshness carries meaning, such as balances or inventory, tighten this to minutes or pair it with the "show how old this is" idea below. Note that gcTime is the in-memory lifetime and is a separate axis from the persisted maxAge. Extend one without the other and you get a blank flash the moment memory is evicted.
On the app side you just wrap with PersistQueryClientProvider. Because Rork regenerates app/_layout.tsx, I keep the wiring here down to a single line (the reasoning comes later).
// the minimal wiring point in app/_layout.tsx
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { queryClient, persister } from "../guarded/offline/queryClient";
export default function RootLayout() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
>
{/* the existing tree Rork generates */}
</PersistQueryClientProvider>
);
}Never lose an offline tap: a write queue with optimistic updates
Persistence removed the blank screen on reads. Writes are next. When someone taps "add to favorites" while offline and nothing happens, they forget they ever tapped it. The quiet rating-killer was people noticing later, once back online, that it was never saved.
The design call here is to reflect the write on screen immediately and defer the send. TanStack Query's onMutate updates the UI optimistically and onError rolls back. On top of that, while offline I hold the send itself and push it onto a local queue.
// guarded/offline/mutationQueue.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
export type PendingMutation = {
id: string; // unique key used to reject duplicate sends
kind: "favorite.add" | "favorite.remove";
payload: Record<string, unknown>;
createdAt: number;
};
const QUEUE_KEY = "RORK_PENDING_MUTATIONS_V1";
export async function enqueue(m: PendingMutation): Promise<void> {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
const list: PendingMutation[] = raw ? JSON.parse(raw) : [];
// if the same id is already queued, do not push again (double-tap / retry)
if (list.some((x) => x.id === m.id)) return;
list.push(m);
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(list));
}
export async function getQueue(): Promise<PendingMutation[]> {
const raw = await AsyncStorage.getItem(QUEUE_KEY);
return raw ? JSON.parse(raw) : [];
}
export async function removeFromQueue(id: string): Promise<void> {
const list = await getQueue();
await AsyncStorage.setItem(
QUEUE_KEY,
JSON.stringify(list.filter((x) => x.id !== id)),
);
}The "add favorite" useMutation looks like this. Even when the send fails (including offline), it enqueues and lets the UI move forward optimistically.
// guarded/offline/useFavoriteMutation.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { enqueue } from "./mutationQueue";
import { addFavoriteRequest } from "../../app/api/favorites"; // send fn in the generated code
export function useAddFavorite() {
const qc = useQueryClient();
return useMutation({
mutationFn: (itemId: string) => addFavoriteRequest(itemId),
onMutate: async (itemId) => {
await qc.cancelQueries({ queryKey: ["favorites"] });
const prev = qc.getQueryData<string[]>(["favorites"]) ?? [];
// update the screen immediately (optimistic update)
qc.setQueryData<string[]>(["favorites"], [...prev, itemId]);
return { prev };
},
onError: async (_err, itemId, ctx) => {
// a failed send likely means offline. Do not revert the UI; stash to the queue
await enqueue({
id: `fav-add-${itemId}`,
kind: "favorite.add",
payload: { itemId },
createdAt: Date.now(),
});
// The key is NOT rolling back to ctx.prev here. Rolling back means "I tapped it and it vanished."
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ["favorites"] });
},
});
}The part I agonized over was onError. The textbook version rolls back to ctx.prev. But offline-first, rolling back produces the worst experience: the item disappears the instant you tap it. I decided to treat a failed send as "send it later," keeping the UI advanced while stashing the action. To be stricter, branch on the error type so that a genuine server error (like a permission failure) does roll back.