How It Works
The Big Picture
Everything revolves around a single OfflineManager singleton. It's just a class instance that lives for the entire app lifecycle. It holds the queue in memory, persists it to storage, and runs the sync logic.
Your components never talk to OfflineManager directly — they use hooks. The hooks read from the manager's internal stores via useSyncExternalStore, which means React only re-renders the components that actually care about the data that changed.
What happens when the user taps a button
Let's say you have a "Like" button:
const { mutateOffline, isLoading, isQueued } = useOfflineMutation('LIKE_POST', {
handler: async (payload) => {
await api.likePost(payload);
},
onOptimisticSuccess: () => setLiked(true),
});When the user is online
- User taps →
mutateOffline({ postId: 42 })is called - Hook detects the device is online
handler(payload)runs immediately — your real API call fires- If it succeeds →
onOptimisticSuccessfires,statusbecomes'success' - If it fails → action is pushed to the queue as a fallback,
statusbecomes'queued'
When the user is offline
- User taps →
mutateOffline({ postId: 42 })is called - Hook detects the device is offline
- Action is pushed to the queue:
{ actionName: 'LIKE_POST', payload: { postId: 42 } } - Queue is persisted to storage (MMKV, AsyncStorage, etc.)
onOptimisticSuccessfires → UI updates immediatelystatusbecomes'queued'
No API call happens. The action just waits.
When connectivity returns
OfflineProviderdetects network change via NetInfo- Depending on
syncMode:- auto →
flushQueue()runs immediately, silently - manual → your
onOnlineRestorecallback fires (you show an Alert, Toast, etc.)
- auto →
flushQueue()loops through every queued action- For each action, it looks up the handler: per-action handler first, then
onSyncActionfallback - Success → action removed from queue. Failure →
retryCountincremented, stays in queue.
How handler resolution works
When the queue flushes, each action needs to find its handler. The resolution order is:
// 1. Check per-action handler registry
const handler = actionHandlers.get(action.actionName);
// 2. If not found, fall back to global onSyncAction
if (!handler && onSyncAction) {
onSyncAction(action);
}
// 3. Neither exists? Action fails with an error.Per-action handlers are registered automatically when useOfflineMutation mounts with a handler option. They persist even after the component unmounts — so if the user navigates away from a screen, the handler is still available for sync.
How storage works
The queue lives in memory (a plain array) for fast access. Every time it changes — push, remove, clear — it's also persisted to your storage adapter.
There are two types of adapters:
Key-value (MMKV, AsyncStorage, Memory): The entire queue is serialized as one JSON string. Simple, but every write rewrites the whole queue.
// What happens internally
await storage.setItem('OFFLINE_QUEUE', JSON.stringify(queue));Record-based (Realm): Each action is a separate database record. Adding or removing one item doesn't touch the rest. Much better for large queues.
// What happens internally
await realm.create('OfflineQueueItem', action);Why useSyncExternalStore
Every hook in this package — useNetworkStatus, useOfflineQueue, useSyncProgress — reads from a store on the OfflineManager singleton using React's useSyncExternalStore.
This matters because:
- No Context cascading. When
isOnlinechanges, only components that calluseNetworkStatus()re-render. Everything else is untouched. - No unnecessary renders. If the queue changes, only
useOfflineQueue()consumers re-render. Network status consumers don't. - Works outside React.
OfflineManageris a plain class. You can readOfflineManager.isOnlineor callOfflineManager.flushQueue()from background tasks, service layers, or anywhere.
Compare this to a typical Context approach where changing one value re-renders every component inside the Provider.
Mutation state lifecycle
Each useOfflineMutation call tracks its own status:
| Scenario | Flow |
|---|---|
| Online, API succeeds | idle → loading → success |
| Online, API fails | idle → loading → queued (action saved as fallback) |
| Offline | idle → queued (action saved, UI updates optimistically) |
You can use reset() to go back to idle at any time.