The one place a shopping app absolutely has to work is the one place phones give up: the middle of a supermarket, three aisles deep, no signal. So this had to be true — check off milk, add oat milk, fix a quantity, all offline, and have every change land silently once you're back on WiFi at the checkout. No spinner of death, no lost taps.
The surprise: I didn't need a sync engine to do it. React Query already pauses, persists, and replays mutations. Two small tricks make it survive a real trip to the store.
TL;DR — For offline-first on Amplify, don't reach for DataStore — stay in TanStack Query. Persist the query cache to AsyncStorage; wire
onlineManagerto NetInfo so React Query pauses mutations offline; make those mutations optimistic and register them as keyed mutation defaults so a paused mutation can replay viaresumePausedMutationseven after the app is killed; and give every create a client-generated UUID so an offline row keeps its identity on sync. No schema change.
(Part 7 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The fork: DataStore, or stay in React Query
Amplify ships DataStore — a genuine offline sync engine with a local store and conflict resolution. It's the "official" answer, and for a collaborative, multi-user, heavily-relational app it's the right one.
But adopting it here meant ripping out the data layer the whole app already stands on: the typed Amplify client, observeQuery, and TanStack Query for server state. That's a big architectural U-turn to serve one feature — and it trades a stack I understand for a sync engine I'd have to learn the failure modes of.
The other option: TanStack Query already has first-class offline support. The app is single-user (your own grocery list), so last-write-wins is a perfectly fine conflict policy, and I get to keep everything I've already built. I stayed. The rest of this post is what "staying" actually takes.
The shape
persist the cache → AsyncStorage, so last-known lists/items read with no network
onlineManager ← NetInfo → React Query knows it's offline, pauses mutations
optimistic mutations as defaults → UI reacts instantly; paused ones replay after an app kill
client-generated IDs → an offline create keeps its id once it syncs
sync banner → the one visible bit: "offline" / "syncing N changes"
No schema change. The models already have createdAt/updatedAt; conflict policy is last-write-wins.
Piece 1 — persist the cache
Offline reads come from the query cache, so the cache has to outlive the process. PersistQueryClientProvider + an AsyncStorage persister does that:
// lib/query-persist.ts
const BUSTER = "cannycart-1"; // bump to discard old cache when the shape changes
const asyncPersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: "cannycart:rq-cache",
throttleTime: 1000,
});
export const persistOptions = {
persister: asyncPersister,
buster: BUSTER,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
dehydrateOptions: {
// Only persist paused offline mutations we can actually resume — the ones
// we registered defaults for (mutationKey ["shopping", …]). Anything else
// paused offline is dropped rather than restored un-resumable.
shouldDehydrateMutation: (m) =>
m.state.isPaused &&
Array.isArray(m.options.mutationKey) &&
m.options.mutationKey[0] === "shopping",
},
};
That shouldDehydrateMutation filter matters more than it looks — persisting a paused mutation you can't rebuild on the next launch is worse than dropping it, because it resurrects as a zombie with no function to run. Only persist what you can resume. (Which is Piece 3.)
Piece 2 — know when you're offline
React Query doesn't detect connectivity itself — you tell it, by pointing onlineManager at NetInfo. One side-effect module:
// lib/react-query-native.ts — wires onlineManager to NetInfo
import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(!!state.isConnected)),
);
Now, when a mutation fires offline, React Query pauses it instead of failing it — and that pause is the hook everything else hangs off.
Piece 3 — the trick that survives an app kill
Here's the part that took the most thought. A paused mutation is easy to resume within a session. But a shopper puts the phone in their pocket; iOS kills the app; they reopen it in the car. The paused mutation was persisted to disk — but a function can't be serialized. So on relaunch, how does React Query know what to run?
The answer is mutation defaults keyed by mutationKey. You register each mutation's function at module load, under a stable key. On relaunch, React Query rehydrates the paused mutation, looks up the default by its key, and rebuilds the function to replay:
// lib/shopping-mutations.ts
export const MK = {
addItems: ["shopping", "addItems"] as const,
toggleItem: ["shopping", "toggleItem"] as const,
// …updateItem, softDeleteItem, clearChecked
};
queryClient.setMutationDefaults(MK.toggleItem, {
mutationFn: async ({ id, checked }: ToggleItemVars) => {
const { data } = await client.models.ShoppingItem.update({ id, checked });
return data;
},
onMutate: ({ id, listId, checked }: ToggleItemVars) =>
optimistic(listId, (rows) => rows.map((r) => (r.id === id ? { ...r, checked } : r))),
onError: (_e, _v, ctx) => rollback(ctx as Ctx),
onSettled: (_d, _e, vars: ToggleItemVars) => reconcile(vars.listId),
});
The onMutate/onError/onSettled trio is the optimistic loop: snapshot the cache and apply the change immediately, roll back if it errors, and reconcile with the server once it settles. The snapshot helper is small:
async function optimistic(listId, apply) {
await queryClient.cancelQueries({ queryKey: itemsKey(listId) });
const prev = queryClient.getQueryData(itemsKey(listId));
queryClient.setQueryData(itemsKey(listId), (old) =>
Array.isArray(old) ? apply([...old]) : old);
return { prev, listId }; // for rollback
}
Because the function lives in a keyed default (not inline at the call site), it exists again the instant the module loads on the next launch — ready for the persisted mutation to find.
Piece 4 — client-generated IDs
Optimistic-add has a classic bug: you show a row offline with some temporary id, the server later assigns the real id, and now your offline edits/toggles point at a ghost. Amplify lets you supply the id on create, so I stamp a UUID in onMutate — into the variables themselves, so the same id flows through the optimistic row, the eventual server row, and the persisted paused mutation:
onMutate: async (vars: AddItemsVars) => {
vars.items.forEach((it) => { if (!it.id) it.id = uuidv4(); }); // stamp stable ids
// …build optimistic rows using it.id…
},
mutationFn: async ({ listId, items }: AddItemsVars) => {
for (const it of items) {
await client.models.ShoppingItem.create({ id: it.id, listId, /* … */ });
}
},
One identity from creation to sync. An item added in aisle 5 and checked off in aisle 7 is the same row when it all lands — no duplicate, no orphaned edit.
Piece 5 — a calm sync banner
The only visible surface is a thin status bar, and it's deliberately teal, not red — being offline isn't an error, it's a normal state of a grocery trip. It reads connectivity and how many ["shopping"] mutations are still in flight:
export function useSyncStatus() {
const [online, setOnline] = useState(() => onlineManager.isOnline());
useEffect(() => onlineManager.subscribe(() => setOnline(onlineManager.isOnline())), []);
const pending = useIsMutating({ mutationKey: ["shopping"] });
return { online, pending };
}
Offline → "changes save here and sync when you're back." Online with a queue → "Syncing N changes…". Online and empty → the banner returns null and vanishes.
The wiring order that bites
There's one ordering trap. resumePausedMutations can only rebuild a persisted mutation if its default is already registered. So the module that registers the defaults must be imported before the provider tries to resume:
// app/_layout.tsx
import "@/lib/shopping-mutations"; // ← registers defaults FIRST (side-effect import)
<PersistQueryClientProvider
client={queryClient}
persistOptions={persistOptions}
onSuccess={() => {
// Cache restored — now replay anything queued while offline.
queryClient.resumePausedMutations();
}}
>
Resume before the defaults exist and the paused mutations rehydrate with no function to run — they just sit there. The fix is literally import order.
How it's holding up (and the caveats)
- Last-write-wins. Fine for a single-user list. The day list sharing lands, that's a different post — two people editing one list needs real conflict resolution, and that's the case where DataStore earns its weight.
-
Voice stays online-only.
parseShoppingListneeds the network and Claude, so the mic politely no-ops offline. Offline covers manual add / check / edit / delete — which is the whole in-store loop. - Replay ordering. A create has to land before the edits made against it. Client-generated ids plus in-order queueing handle this: the same id is valid the moment the row is optimistically created, so a follow-up toggle is never "ahead" of its target.
When you should choose DataStore instead
Stay in React Query when you're single-user, last-write-wins is acceptable, and you already own your data layer. Reach for DataStore (or a purpose-built sync engine) when you have multi-user shared documents, need real conflict resolution, or have deeply relational data that has to stay consistent offline. Offline-first is a spectrum; this is the light, boring end of it — which is exactly what a personal shopping list wants.
What's the offline story in your app — full sync engine, or the React-Query-pause-and-replay approach? And if you've shipped shared/collaborative offline, I'd love to read how you handled the conflicts I get to ignore.


Top comments (0)