RORK LABJP
MAX — Rork Max is built on Claude Code and Claude Opus 4.6, generating native Swift apps directly instead of React NativeAPPLE — Rork Max targets the whole Apple ecosystem: iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageWORKFLOW — In practice, users settle into letting the AI scaffold while they rewrite the state management and data layer themselvesSEED — Rork raised a $15M seed led by Left Lane Capital in April, with Peak XV, True Ventures, and a16z Speedrun joiningPAPERLINE — Rork acquired app builder Paperline and says it will stay acquisitive to bring in engineering talentREVIEW — Three-month revisit reviews are growing, clarifying where the tool shines and where it doesn'tMAX — Rork Max is built on Claude Code and Claude Opus 4.6, generating native Swift apps directly instead of React NativeAPPLE — Rork Max targets the whole Apple ecosystem: iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageWORKFLOW — In practice, users settle into letting the AI scaffold while they rewrite the state management and data layer themselvesSEED — Rork raised a $15M seed led by Left Lane Capital in April, with Peak XV, True Ventures, and a16z Speedrun joiningPAPERLINE — Rork acquired app builder Paperline and says it will stay acquisitive to bring in engineering talentREVIEW — Three-month revisit reviews are growing, clarifying where the tool shines and where it doesn't
Articles/Dev Tools
Dev Tools/2026-06-18Advanced

Retrofitting Offline-First Into a Rork App: Persistent Cache and a Write Queue

Reviews kept saying the app was blank on the subway. Polishing error screens was not enough, so I retrofitted TanStack Query persistence and an offline write queue into a Rork-generated Expo app. Optimistic updates, reconnect flushing, and keeping the layer safe from regeneration are all covered with code.

Rork502Expo139offline-first3TanStack Queryindie developer37

Premium Article

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.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
A persistQueryClient + AsyncStorage setup that paints last session's data on launch, with the reasoning behind a 24-hour maxAge
An optimistic-update mutation queue that never loses an offline tap, plus the full NetInfo code that flushes it once on reconnect
How to keep the retrofitted layer out of Rork's regeneration path with a guarded/ folder, and the duplicate-flush and stale-data bugs I hit in production
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-07-04
Should You Show a Read More Link? Let the Rendered Text Decide in Rork (Expo)
Clamping a product description to three lines and adding a Read more toggle sounds simple, until the toggle also appears under single-line text. This walks through measuring the real line count with onTextLayout so the toggle only shows when text actually overflows, covering iOS vs Android quirks, expand animation, and font scaling.
Dev Tools2026-06-30
Adding Home-Screen Quick Actions to a Rork App — dynamic items without cold-launch drops
How to implement the long-press quick actions on a Rork (Expo) app icon. Covers static vs dynamic items, the iOS/Android differences, and the cold-launch problem where the action arrives before the router is ready — solved with a hold-and-replay design.
Dev Tools2026-06-28
Ship EAS Updates to a Few First, and Halt Automatically on Crash Rate
Because OTA updates reach everyone instantly, a bad update reaches everyone instantly too. Here is a three-layer design: ship EAS Update to a small canary, decide expand-or-halt from crash-free rate automatically, and hold a safety net on the device — with working code.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →