@mustafaaksoy41/react-native-offline-queue 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +673 -0
- package/lib/commonjs/adapters/index.js +128 -0
- package/lib/commonjs/adapters/index.js.map +1 -0
- package/lib/commonjs/components/OfflineProvider.js +51 -0
- package/lib/commonjs/components/OfflineProvider.js.map +1 -0
- package/lib/commonjs/components/OfflineSyncPrompt.js +37 -0
- package/lib/commonjs/components/OfflineSyncPrompt.js.map +1 -0
- package/lib/commonjs/core/OfflineManager.js +308 -0
- package/lib/commonjs/core/OfflineManager.js.map +1 -0
- package/lib/commonjs/core/StorageAdapter.js +31 -0
- package/lib/commonjs/core/StorageAdapter.js.map +1 -0
- package/lib/commonjs/core/types.js +15 -0
- package/lib/commonjs/core/types.js.map +1 -0
- package/lib/commonjs/global.d.js +2 -0
- package/lib/commonjs/global.d.js.map +1 -0
- package/lib/commonjs/hooks/useOfflineMutation.js +61 -0
- package/lib/commonjs/hooks/useOfflineMutation.js.map +1 -0
- package/lib/commonjs/hooks/useOfflineQueue.js +21 -0
- package/lib/commonjs/hooks/useOfflineQueue.js.map +1 -0
- package/lib/commonjs/hooks/useOfflineSyncInterceptor.js +42 -0
- package/lib/commonjs/hooks/useOfflineSyncInterceptor.js.map +1 -0
- package/lib/commonjs/hooks/useSyncProgress.js +33 -0
- package/lib/commonjs/hooks/useSyncProgress.js.map +1 -0
- package/lib/commonjs/index.js +134 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/module/adapters/index.js +121 -0
- package/lib/module/adapters/index.js.map +1 -0
- package/lib/module/components/OfflineProvider.js +43 -0
- package/lib/module/components/OfflineProvider.js.map +1 -0
- package/lib/module/components/OfflineSyncPrompt.js +31 -0
- package/lib/module/components/OfflineSyncPrompt.js.map +1 -0
- package/lib/module/core/OfflineManager.js +304 -0
- package/lib/module/core/OfflineManager.js.map +1 -0
- package/lib/module/core/StorageAdapter.js +25 -0
- package/lib/module/core/StorageAdapter.js.map +1 -0
- package/lib/module/core/types.js +11 -0
- package/lib/module/core/types.js.map +1 -0
- package/lib/module/global.d.js +2 -0
- package/lib/module/global.d.js.map +1 -0
- package/lib/module/hooks/useOfflineMutation.js +57 -0
- package/lib/module/hooks/useOfflineMutation.js.map +1 -0
- package/lib/module/hooks/useOfflineQueue.js +17 -0
- package/lib/module/hooks/useOfflineQueue.js.map +1 -0
- package/lib/module/hooks/useOfflineSyncInterceptor.js +38 -0
- package/lib/module/hooks/useOfflineSyncInterceptor.js.map +1 -0
- package/lib/module/hooks/useSyncProgress.js +29 -0
- package/lib/module/hooks/useSyncProgress.js.map +1 -0
- package/lib/module/index.js +20 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/adapters/index.d.ts +12 -0
- package/lib/typescript/adapters/index.d.ts.map +1 -0
- package/lib/typescript/components/OfflineProvider.d.ts +13 -0
- package/lib/typescript/components/OfflineProvider.d.ts.map +1 -0
- package/lib/typescript/components/OfflineSyncPrompt.d.ts +11 -0
- package/lib/typescript/components/OfflineSyncPrompt.d.ts.map +1 -0
- package/lib/typescript/core/OfflineManager.d.ts +53 -0
- package/lib/typescript/core/OfflineManager.d.ts.map +1 -0
- package/lib/typescript/core/StorageAdapter.d.ts +21 -0
- package/lib/typescript/core/StorageAdapter.d.ts.map +1 -0
- package/lib/typescript/core/types.d.ts +23 -0
- package/lib/typescript/core/types.d.ts.map +1 -0
- package/lib/typescript/hooks/useOfflineMutation.d.ts +8 -0
- package/lib/typescript/hooks/useOfflineMutation.d.ts.map +1 -0
- package/lib/typescript/hooks/useOfflineQueue.d.ts +8 -0
- package/lib/typescript/hooks/useOfflineQueue.d.ts.map +1 -0
- package/lib/typescript/hooks/useOfflineSyncInterceptor.d.ts +9 -0
- package/lib/typescript/hooks/useOfflineSyncInterceptor.d.ts.map +1 -0
- package/lib/typescript/hooks/useSyncProgress.d.ts +23 -0
- package/lib/typescript/hooks/useSyncProgress.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +11 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/package.json +73 -0
- package/src/adapters/index.ts +141 -0
- package/src/components/OfflineProvider.tsx +52 -0
- package/src/components/OfflineSyncPrompt.tsx +32 -0
- package/src/core/OfflineManager.ts +338 -0
- package/src/core/StorageAdapter.ts +42 -0
- package/src/core/types.ts +33 -0
- package/src/global.d.ts +1 -0
- package/src/hooks/useOfflineMutation.ts +63 -0
- package/src/hooks/useOfflineQueue.ts +17 -0
- package/src/hooks/useOfflineSyncInterceptor.ts +39 -0
- package/src/hooks/useSyncProgress.ts +32 -0
- package/src/index.ts +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mustafa Aksoy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 📡 react-native-offline-queue
|
|
4
|
+
|
|
5
|
+
**A lightweight, high-performance offline queue and sync manager for React Native.**
|
|
6
|
+
Queue operations when offline, sync automatically or manually when connectivity returns.
|
|
7
|
+
|
|
8
|
+
<br />
|
|
9
|
+
|
|
10
|
+
<!-- Package Info -->
|
|
11
|
+
[](https://www.npmjs.com/package/@mustafaaksoy41/react-native-offline-queue)
|
|
12
|
+
[](https://www.npmjs.com/package/@mustafaaksoy41/react-native-offline-queue)
|
|
13
|
+
[](https://github.com/mustafaaksoy41/react-native-offline-queue/blob/main/LICENSE)
|
|
14
|
+
[](https://bundlephobia.com/package/@mustafaaksoy41/react-native-offline-queue)
|
|
15
|
+
|
|
16
|
+
<!-- Platform & Language -->
|
|
17
|
+
[](https://reactnative.dev/)
|
|
18
|
+
[](https://reactnative.dev/)
|
|
19
|
+
[](https://www.typescriptlang.org/)
|
|
20
|
+
[](https://reactnative.dev/)
|
|
21
|
+
|
|
22
|
+
<!-- Supported Storage Adapters -->
|
|
23
|
+
[](https://github.com/mrousavy/react-native-mmkv)
|
|
24
|
+
[](https://react-native-async-storage.github.io/async-storage/)
|
|
25
|
+
[](https://www.mongodb.com/docs/realm/sdk/react-native/)
|
|
26
|
+
[](#storage-adapters)
|
|
27
|
+
|
|
28
|
+
<!-- Core Dependency -->
|
|
29
|
+
[](https://github.com/react-native-netinfo/react-native-netinfo)
|
|
30
|
+
|
|
31
|
+
<br />
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<b>🔌 Offline-First</b> · <b>⚡ Optimistic UI</b> · <b>🔄 Auto / Manual Sync</b> · <b>📦 Pluggable Storage</b> · <b>📊 Live Progress</b>
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **Offline-first mutations** — API calls are queued when offline, executed when online
|
|
44
|
+
- **Optimistic UI updates** — UI responds instantly, data syncs in the background
|
|
45
|
+
- **Flexible sync modes** — `auto` (silent sync) or `manual` (prompt the user)
|
|
46
|
+
- **Pluggable storage** — MMKV, AsyncStorage, or bring your own adapter
|
|
47
|
+
- **Live sync progress** — track each item as it syncs (pending → syncing → success/failed)
|
|
48
|
+
- **Zero unnecessary renders** — built on `useSyncExternalStore`
|
|
49
|
+
- **Customizable restore UI** — Alert, Toast, BottomSheet, or silent — you decide
|
|
50
|
+
- **Background task compatible** — use `OfflineManager.flushQueue()` from any context
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm install @mustafaaksoy41/react-native-offline-queue
|
|
56
|
+
|
|
57
|
+
# Required peer dependency
|
|
58
|
+
npm install @react-native-community/netinfo
|
|
59
|
+
|
|
60
|
+
# Pick ONE storage adapter (optional — defaults to in-memory)
|
|
61
|
+
npm install react-native-mmkv # Recommended: fast, synchronous
|
|
62
|
+
# OR
|
|
63
|
+
npm install @react-native-async-storage/async-storage
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### iOS
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cd ios && pod install
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
### 1. Wrap your app with `OfflineProvider`
|
|
75
|
+
|
|
76
|
+
You can handle sync in two ways. Pick the one that fits your project:
|
|
77
|
+
|
|
78
|
+
**Option A — Per-action handlers (recommended)**
|
|
79
|
+
|
|
80
|
+
Each component defines its own API call. Provider stays clean:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
// App.tsx
|
|
84
|
+
import { OfflineProvider } from '@mustafaaksoy41/react-native-offline-queue';
|
|
85
|
+
|
|
86
|
+
export default function App() {
|
|
87
|
+
return (
|
|
88
|
+
<OfflineProvider config={{ storageType: 'mmkv', syncMode: 'auto' }}>
|
|
89
|
+
<YourApp />
|
|
90
|
+
</OfflineProvider>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Option B — Centralized handler**
|
|
96
|
+
|
|
97
|
+
One global function handles all actions. Useful if you want a single place to manage API calls:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
// App.tsx
|
|
101
|
+
import { OfflineProvider } from '@mustafaaksoy41/react-native-offline-queue';
|
|
102
|
+
|
|
103
|
+
const offlineConfig = {
|
|
104
|
+
storageType: 'mmkv',
|
|
105
|
+
syncMode: 'auto',
|
|
106
|
+
onSyncAction: async (action) => {
|
|
107
|
+
switch (action.actionName) {
|
|
108
|
+
case 'LIKE_POST':
|
|
109
|
+
await api.likePost(action.payload);
|
|
110
|
+
break;
|
|
111
|
+
case 'CREATE_POST':
|
|
112
|
+
await api.createPost(action.payload);
|
|
113
|
+
break;
|
|
114
|
+
case 'SEND_MESSAGE':
|
|
115
|
+
await api.sendMessage(action.payload);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export default function App() {
|
|
122
|
+
return (
|
|
123
|
+
<OfflineProvider config={offlineConfig}>
|
|
124
|
+
<YourApp />
|
|
125
|
+
</OfflineProvider>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 2. Use mutations in your components
|
|
131
|
+
|
|
132
|
+
**With per-action handler (Option A):**
|
|
133
|
+
|
|
134
|
+
Each component defines its own API call via `handler`:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
import { useOfflineMutation } from '@mustafaaksoy41/react-native-offline-queue';
|
|
138
|
+
|
|
139
|
+
function LikeButton({ postId }) {
|
|
140
|
+
const [liked, setLiked] = useState(false);
|
|
141
|
+
|
|
142
|
+
const { mutateOffline } = useOfflineMutation('LIKE_POST', {
|
|
143
|
+
handler: async (payload) => {
|
|
144
|
+
await fetch('https://api.example.com/likes', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'Content-Type': 'application/json' },
|
|
147
|
+
body: JSON.stringify(payload),
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
onOptimisticSuccess: () => setLiked(true),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<Button
|
|
155
|
+
title={liked ? '❤️' : '🤍'}
|
|
156
|
+
onPress={() => mutateOffline({ postId })}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Without handler (Option B):**
|
|
163
|
+
|
|
164
|
+
If you're using a centralized `onSyncAction`, just skip the `handler` — the global function will handle it:
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
function LikeButton({ postId }) {
|
|
168
|
+
const [liked, setLiked] = useState(false);
|
|
169
|
+
|
|
170
|
+
const { mutateOffline } = useOfflineMutation('LIKE_POST', {
|
|
171
|
+
onOptimisticSuccess: () => setLiked(true),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Button
|
|
176
|
+
title={liked ? '❤️' : '🤍'}
|
|
177
|
+
onPress={() => mutateOffline({ postId })}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**How it works:**
|
|
184
|
+
- **Online**: The handler (or `onSyncAction`) runs immediately. No queue involved.
|
|
185
|
+
- **Offline**: The action is saved to the queue, and `onOptimisticSuccess` fires so the UI updates instantly.
|
|
186
|
+
- **When connectivity returns**: Queued actions are synced — per-action handler first, then `onSyncAction` as fallback.
|
|
187
|
+
|
|
188
|
+
### Full Example
|
|
189
|
+
|
|
190
|
+
Here's what a real app looks like with multiple offline-capable actions. Each component owns its own API logic — no central switch-case needed.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
// App.tsx
|
|
194
|
+
import { OfflineProvider } from '@mustafaaksoy41/react-native-offline-queue';
|
|
195
|
+
|
|
196
|
+
export default function App() {
|
|
197
|
+
return (
|
|
198
|
+
<OfflineProvider config={{ storageType: 'mmkv', syncMode: 'auto' }}>
|
|
199
|
+
<HomeScreen />
|
|
200
|
+
</OfflineProvider>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
// CreatePostForm.tsx
|
|
207
|
+
import { useOfflineMutation } from '@mustafaaksoy41/react-native-offline-queue';
|
|
208
|
+
|
|
209
|
+
function CreatePostForm() {
|
|
210
|
+
const { mutateOffline } = useOfflineMutation('CREATE_POST', {
|
|
211
|
+
handler: async (payload) => {
|
|
212
|
+
await fetch('/api/posts', {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
body: JSON.stringify(payload),
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
onOptimisticSuccess: (payload) => {
|
|
218
|
+
// Add to local list immediately
|
|
219
|
+
setPosts((prev) => [...prev, { ...payload, id: 'temp', pending: true }]);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return <Button title="Post" onPress={() => mutateOffline({ title, body })} />;
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
// CommentSection.tsx
|
|
229
|
+
function CommentSection({ postId }) {
|
|
230
|
+
const { mutateOffline } = useOfflineMutation('ADD_COMMENT', {
|
|
231
|
+
handler: async (payload) => {
|
|
232
|
+
await fetch(`/api/posts/${payload.postId}/comments`, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
body: JSON.stringify({ text: payload.text }),
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
onOptimisticSuccess: (payload) => {
|
|
238
|
+
setComments((prev) => [...prev, { text: payload.text, pending: true }]);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return <Button title="Comment" onPress={() => mutateOffline({ postId, text })} />;
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
// MessageBubble.tsx
|
|
248
|
+
function MessageBubble({ chatId }) {
|
|
249
|
+
const { mutateOffline } = useOfflineMutation('SEND_MESSAGE', {
|
|
250
|
+
handler: async (payload) => {
|
|
251
|
+
await fetch(`/api/chats/${payload.chatId}/messages`, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
body: JSON.stringify({ text: payload.text }),
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
onOptimisticSuccess: (payload) => {
|
|
257
|
+
addMessage({ text: payload.text, status: 'sending' });
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return <Button title="Send" onPress={() => mutateOffline({ chatId, text: message })} />;
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Each `handler` is self-contained: when the user goes offline, actions are queued with their `actionName`. When connectivity returns, the queue flushes and each action runs through its registered handler automatically.
|
|
266
|
+
|
|
267
|
+
## Configuration
|
|
268
|
+
|
|
269
|
+
```tsx
|
|
270
|
+
interface OfflineManagerConfig {
|
|
271
|
+
// Storage backend for persisting the queue
|
|
272
|
+
storageType?: 'mmkv' | 'async-storage' | 'memory';
|
|
273
|
+
storage?: StorageAdapter; // Or pass a custom adapter
|
|
274
|
+
|
|
275
|
+
// Sync behavior when connectivity restores
|
|
276
|
+
syncMode?: 'auto' | 'manual';
|
|
277
|
+
|
|
278
|
+
// Key used for storage persistence
|
|
279
|
+
storageKey?: string;
|
|
280
|
+
|
|
281
|
+
// Handler that processes each queued action during sync
|
|
282
|
+
onSyncAction?: (action: OfflineAction) => Promise<void>;
|
|
283
|
+
|
|
284
|
+
// Called when device goes online with pending items (manual mode only)
|
|
285
|
+
onOnlineRestore?: (params: {
|
|
286
|
+
pendingCount: number;
|
|
287
|
+
syncNow: () => Promise<void>;
|
|
288
|
+
discardQueue: () => Promise<void>;
|
|
289
|
+
}) => void;
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Sync Modes
|
|
294
|
+
|
|
295
|
+
| Mode | Behavior |
|
|
296
|
+
|------|----------|
|
|
297
|
+
| `auto` | Queue is flushed silently as soon as connectivity returns |
|
|
298
|
+
| `manual` | `onOnlineRestore` callback fires — you decide what to show |
|
|
299
|
+
|
|
300
|
+
## Handling Online Restore (Manual Mode)
|
|
301
|
+
|
|
302
|
+
When `syncMode` is `'manual'`, you control what happens when the device goes back online. Set the `onOnlineRestore` callback in your config.
|
|
303
|
+
|
|
304
|
+
### Option A: Alert
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
onOnlineRestore: ({ pendingCount, syncNow, discardQueue }) => {
|
|
308
|
+
Alert.alert(
|
|
309
|
+
'Back Online',
|
|
310
|
+
`${pendingCount} pending operations. Sync now?`,
|
|
311
|
+
[
|
|
312
|
+
{ text: 'Later', style: 'cancel' },
|
|
313
|
+
{ text: 'Discard', style: 'destructive', onPress: discardQueue },
|
|
314
|
+
{ text: 'Sync', onPress: syncNow },
|
|
315
|
+
]
|
|
316
|
+
);
|
|
317
|
+
},
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Option B: Toast
|
|
321
|
+
|
|
322
|
+
```tsx
|
|
323
|
+
import Toast from 'react-native-toast-message';
|
|
324
|
+
|
|
325
|
+
onOnlineRestore: ({ pendingCount, syncNow }) => {
|
|
326
|
+
Toast.show({
|
|
327
|
+
type: 'info',
|
|
328
|
+
text1: 'Back online',
|
|
329
|
+
text2: `Tap to sync ${pendingCount} pending operations`,
|
|
330
|
+
onPress: () => {
|
|
331
|
+
syncNow();
|
|
332
|
+
Toast.hide();
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
},
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Option C: Bottom Sheet
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
import { bottomSheetRef } from './BottomSheetController';
|
|
342
|
+
|
|
343
|
+
onOnlineRestore: ({ pendingCount, syncNow }) => {
|
|
344
|
+
// Open your bottom sheet and pass sync controls
|
|
345
|
+
bottomSheetRef.current?.present({ pendingCount, syncNow });
|
|
346
|
+
},
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Option D: Silent
|
|
350
|
+
|
|
351
|
+
Omit `onOnlineRestore` entirely. Nothing happens — you handle sync manually through the `useOfflineQueue` hook.
|
|
352
|
+
|
|
353
|
+
## Hooks
|
|
354
|
+
|
|
355
|
+
### `useOfflineMutation(actionName, options?)`
|
|
356
|
+
|
|
357
|
+
Queue-aware mutation hook. Calls the handler directly when online, queues when offline.
|
|
358
|
+
|
|
359
|
+
```tsx
|
|
360
|
+
const { mutateOffline } = useOfflineMutation('CREATE_POST', {
|
|
361
|
+
handler: async (payload) => {
|
|
362
|
+
// Your API call — runs directly when online, or during sync when offline
|
|
363
|
+
await api.createPost(payload);
|
|
364
|
+
},
|
|
365
|
+
onOptimisticSuccess: (payload) => {
|
|
366
|
+
// Runs immediately — update your local state here
|
|
367
|
+
},
|
|
368
|
+
onError: (error, payload) => {
|
|
369
|
+
// Runs if the direct API call fails while online
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
mutateOffline({ title: 'Hello', body: 'World' });
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
| Option | Type | Description |
|
|
377
|
+
|--------|------|-------------|
|
|
378
|
+
| `handler` | `(payload) => Promise<void>` | API call for this specific action. Registered automatically. |
|
|
379
|
+
| `onOptimisticSuccess` | `(payload) => void` | Fires immediately for instant UI updates |
|
|
380
|
+
| `onError` | `(error, payload) => void` | Fires if the direct call fails while online |
|
|
381
|
+
|
|
382
|
+
### `useOfflineQueue()`
|
|
383
|
+
|
|
384
|
+
Access the live queue state. Uses `useSyncExternalStore` under the hood — only re-renders when the queue actually changes.
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
const { queue, pendingCount, isSyncing, syncNow, clearQueue } = useOfflineQueue();
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
| Property | Type | Description |
|
|
391
|
+
|----------|------|-------------|
|
|
392
|
+
| `queue` | `OfflineAction[]` | Current queue contents |
|
|
393
|
+
| `pendingCount` | `number` | Number of pending items |
|
|
394
|
+
| `isSyncing` | `boolean` | Whether a sync is in progress |
|
|
395
|
+
| `syncNow` | `() => Promise<void>` | Trigger a manual sync |
|
|
396
|
+
| `clearQueue` | `() => Promise<void>` | Remove all queued items |
|
|
397
|
+
|
|
398
|
+
### `useNetworkStatus()`
|
|
399
|
+
|
|
400
|
+
Simple connectivity status.
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
const { isOnline } = useNetworkStatus();
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### `useSyncProgress()`
|
|
407
|
+
|
|
408
|
+
Live progress tracking during a sync session. Useful for building progress UIs inside sheets or modals.
|
|
409
|
+
|
|
410
|
+
```tsx
|
|
411
|
+
const {
|
|
412
|
+
isActive, // Is a sync session running?
|
|
413
|
+
totalCount, // Total items in this batch
|
|
414
|
+
completedCount, // Successfully synced
|
|
415
|
+
failedCount, // Items that failed
|
|
416
|
+
percentage, // 0–100
|
|
417
|
+
currentAction, // The action being synced right now
|
|
418
|
+
items, // Per-item status: pending | syncing | success | failed
|
|
419
|
+
} = useSyncProgress();
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Example: Progress list inside a Bottom Sheet**
|
|
423
|
+
|
|
424
|
+
```tsx
|
|
425
|
+
{items.map((item) => (
|
|
426
|
+
<View key={item.action.id} style={styles.row}>
|
|
427
|
+
<Text>{item.status === 'success' ? '✅' : item.status === 'failed' ? '❌' : '⏳'}</Text>
|
|
428
|
+
<Text>{item.action.actionName}</Text>
|
|
429
|
+
</View>
|
|
430
|
+
))}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
## Direct API Access
|
|
434
|
+
|
|
435
|
+
`OfflineManager` is a singleton accessible from anywhere — not just React components. Useful for background tasks, service layers, or testing.
|
|
436
|
+
|
|
437
|
+
```tsx
|
|
438
|
+
import { OfflineManager } from '@mustafaaksoy41/react-native-offline-queue';
|
|
439
|
+
|
|
440
|
+
// Queue an action manually
|
|
441
|
+
await OfflineManager.push('SEND_MESSAGE', { text: 'hello' });
|
|
442
|
+
|
|
443
|
+
// Flush the queue
|
|
444
|
+
await OfflineManager.flushQueue();
|
|
445
|
+
|
|
446
|
+
// Read the queue
|
|
447
|
+
const items = OfflineManager.getQueue();
|
|
448
|
+
|
|
449
|
+
// Clear everything
|
|
450
|
+
await OfflineManager.clear();
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
## Background Sync
|
|
454
|
+
|
|
455
|
+
This package doesn't manage background tasks — that's platform-specific and depends on your setup. But `OfflineManager` works outside of React, so you can call it from any background task runner:
|
|
456
|
+
|
|
457
|
+
```tsx
|
|
458
|
+
import BackgroundFetch from 'react-native-background-fetch';
|
|
459
|
+
import { OfflineManager } from '@mustafaaksoy41/react-native-offline-queue';
|
|
460
|
+
|
|
461
|
+
BackgroundFetch.configure({
|
|
462
|
+
minimumFetchInterval: 15,
|
|
463
|
+
}, async (taskId) => {
|
|
464
|
+
// Re-configure if the app was killed and relaunched
|
|
465
|
+
await OfflineManager.configure({
|
|
466
|
+
storageType: 'mmkv',
|
|
467
|
+
onSyncAction: myApiHandler,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await OfflineManager.flushQueue();
|
|
471
|
+
BackgroundFetch.finish(taskId);
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
This works with any background task library: `react-native-background-fetch`, `expo-task-manager`, iOS `BGTaskScheduler` via a native module, etc.
|
|
476
|
+
|
|
477
|
+
## Storage Adapters
|
|
478
|
+
|
|
479
|
+
### Built-in Adapters
|
|
480
|
+
|
|
481
|
+
| Adapter | Install | Limits | Best For |
|
|
482
|
+
|---------|---------|--------|----------|
|
|
483
|
+
| **MMKV** | `npm i react-native-mmkv` | ~**unlimited** (file-backed, grows dynamically). Per-value performance drops above ~256KB. | Small-to-medium queues (<1000 items). Fastest read/write. |
|
|
484
|
+
| **AsyncStorage** | `npm i @react-native-async-storage/async-storage` | Android: **6MB total** (default). iOS: no hard limit (SQLite). Per-value: **2MB on Android**. | Apps that already use AsyncStorage. |
|
|
485
|
+
| **Memory** | built-in | RAM only — **lost on app kill**. | Development, testing, or ephemeral queues. |
|
|
486
|
+
|
|
487
|
+
> **How much queue data is realistic?**
|
|
488
|
+
> A typical queued action is ~200–500 bytes (JSON). So 1000 queued actions ≈ 500KB — well within limits for both MMKV and AsyncStorage.
|
|
489
|
+
>
|
|
490
|
+
> If your queue could grow beyond 5000+ items or individual payloads exceed 1MB (e.g. base64 images), consider Realm or a custom SQLite adapter.
|
|
491
|
+
|
|
492
|
+
### Using a Built-in Adapter
|
|
493
|
+
|
|
494
|
+
```tsx
|
|
495
|
+
// MMKV (recommended)
|
|
496
|
+
<OfflineProvider config={{ storageType: 'mmkv', ... }} />
|
|
497
|
+
|
|
498
|
+
// AsyncStorage
|
|
499
|
+
<OfflineProvider config={{ storageType: 'async-storage', ... }} />
|
|
500
|
+
|
|
501
|
+
// In-memory (no persistence)
|
|
502
|
+
<OfflineProvider config={{ storageType: 'memory', ... }} />
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Custom Storage Adapter
|
|
506
|
+
|
|
507
|
+
Implement the `StorageAdapter` interface to use any storage backend:
|
|
508
|
+
|
|
509
|
+
```tsx
|
|
510
|
+
import { OfflineProvider, type StorageAdapter } from '@mustafaaksoy41/react-native-offline-queue';
|
|
511
|
+
|
|
512
|
+
const myStorage: StorageAdapter = {
|
|
513
|
+
getItem: async (key) => { /* return string | null */ },
|
|
514
|
+
setItem: async (key, value) => { /* persist string */ },
|
|
515
|
+
removeItem: async (key) => { /* delete key */ },
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
<OfflineProvider config={{ storage: myStorage, syncMode: 'auto', onSyncAction: handler }}>
|
|
519
|
+
<App />
|
|
520
|
+
</OfflineProvider>
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Realm Adapter (Built-in, Record-Based)
|
|
524
|
+
|
|
525
|
+
[Realm](https://www.mongodb.com/docs/realm/sdk/react-native/) stores each queue item as a separate database record. No JSON serialization overhead, no size limits, native query support.
|
|
526
|
+
|
|
527
|
+
```bash
|
|
528
|
+
npm install realm
|
|
529
|
+
cd ios && pod install
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
**Zero-config (default table created automatically):**
|
|
533
|
+
|
|
534
|
+
```tsx
|
|
535
|
+
<OfflineProvider config={{ storageType: 'realm', syncMode: 'auto', onSyncAction: handler }}>
|
|
536
|
+
<App />
|
|
537
|
+
</OfflineProvider>
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
This automatically creates an `OfflineQueueItem` table in a dedicated `offline-queue.realm` file:
|
|
541
|
+
|
|
542
|
+
| Column | Type | Description |
|
|
543
|
+
|--------|------|-------------|
|
|
544
|
+
| `id` | string (PK) | Unique action ID |
|
|
545
|
+
| `actionName` | string | e.g. `'LIKE_POST'` |
|
|
546
|
+
| `payload` | string | JSON-stringified payload |
|
|
547
|
+
| `createdAt` | int | Timestamp |
|
|
548
|
+
| `retryCount` | int | Failed attempt count |
|
|
549
|
+
|
|
550
|
+
**With your own Realm instance (shared with your app's data):**
|
|
551
|
+
|
|
552
|
+
```tsx
|
|
553
|
+
import Realm from 'realm';
|
|
554
|
+
import { OfflineProvider } from '@mustafaaksoy41/react-native-offline-queue';
|
|
555
|
+
|
|
556
|
+
// Your app's Realm schemas + queue schema
|
|
557
|
+
const realm = await Realm.open({
|
|
558
|
+
schema: [UserSchema, PostSchema, OfflineQueueItemSchema],
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
<OfflineProvider config={{
|
|
562
|
+
storageType: 'realm',
|
|
563
|
+
realmOptions: { realmInstance: realm },
|
|
564
|
+
syncMode: 'auto',
|
|
565
|
+
onSyncAction: handler,
|
|
566
|
+
}}>
|
|
567
|
+
<App />
|
|
568
|
+
</OfflineProvider>
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
**Performance comparison:**
|
|
572
|
+
|
|
573
|
+
| Operation | MMKV (key-value) | Realm (record-based) |
|
|
574
|
+
|-----------|-------------------|----------------------|
|
|
575
|
+
| Push 1 item (100 items in queue) | Rewrite 100 items as JSON | Insert 1 record |
|
|
576
|
+
| Push 1 item (10,000 items in queue) | Rewrite 10,000 items as JSON | Insert 1 record |
|
|
577
|
+
| Remove 1 item | Rewrite entire queue | Delete 1 record |
|
|
578
|
+
| Load on startup | Parse entire JSON string | Read table records |
|
|
579
|
+
|
|
580
|
+
**When to use Realm over MMKV:**
|
|
581
|
+
- Queue can grow to 10,000+ items
|
|
582
|
+
- Individual payloads are large (>1MB)
|
|
583
|
+
- You need query/filter capabilities on the queue
|
|
584
|
+
- Your app already uses Realm for other data
|
|
585
|
+
|
|
586
|
+
## Types
|
|
587
|
+
|
|
588
|
+
```tsx
|
|
589
|
+
interface OfflineAction<TPayload = any> {
|
|
590
|
+
id: string;
|
|
591
|
+
actionName: string;
|
|
592
|
+
payload: TPayload;
|
|
593
|
+
createdAt: number;
|
|
594
|
+
retryCount: number;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
type SyncItemStatus = 'pending' | 'syncing' | 'success' | 'failed';
|
|
598
|
+
|
|
599
|
+
interface SyncProgressItem {
|
|
600
|
+
action: OfflineAction;
|
|
601
|
+
status: SyncItemStatus;
|
|
602
|
+
error?: string;
|
|
603
|
+
}
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
## Sync Strategies
|
|
607
|
+
|
|
608
|
+
You have two ways to define how queued actions get synced. Use them together or pick one — the per-action handler always takes priority.
|
|
609
|
+
|
|
610
|
+
### Per-action handler (recommended)
|
|
611
|
+
|
|
612
|
+
Each mutation defines its own API call. The handler is registered automatically when the component mounts, and used during sync. This keeps the API logic next to the component that triggers it.
|
|
613
|
+
|
|
614
|
+
```tsx
|
|
615
|
+
const { mutateOffline } = useOfflineMutation('LIKE_POST', {
|
|
616
|
+
handler: async (payload) => {
|
|
617
|
+
await api.likePost(payload);
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
### Global `onSyncAction` (fallback)
|
|
623
|
+
|
|
624
|
+
A catch-all function in the provider config. Useful as a fallback, or if you prefer managing all your API calls from one place.
|
|
625
|
+
|
|
626
|
+
```tsx
|
|
627
|
+
<OfflineProvider config={{
|
|
628
|
+
storageType: 'mmkv',
|
|
629
|
+
syncMode: 'auto',
|
|
630
|
+
onSyncAction: async (action) => {
|
|
631
|
+
switch (action.actionName) {
|
|
632
|
+
case 'CREATE_POST':
|
|
633
|
+
await api.createPost(action.payload);
|
|
634
|
+
break;
|
|
635
|
+
case 'DELETE_COMMENT':
|
|
636
|
+
await api.deleteComment(action.payload);
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
}}>
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### Resolution order
|
|
644
|
+
|
|
645
|
+
When the queue flushes, each action is resolved like this:
|
|
646
|
+
|
|
647
|
+
1. **Per-action handler** registered via `useOfflineMutation` → used if available
|
|
648
|
+
2. **Global `onSyncAction`** from provider config → used as fallback
|
|
649
|
+
3. **No handler found** → action fails with an error
|
|
650
|
+
|
|
651
|
+
## How It Works
|
|
652
|
+
|
|
653
|
+
The whole thing is built around a single `OfflineManager` singleton. It holds the queue in memory, persists it through whichever storage adapter you pick, and handles the sync logic.
|
|
654
|
+
|
|
655
|
+
`OfflineProvider` wraps your app and wires everything up: it listens for connectivity changes via NetInfo, configures the manager with your settings, and exposes the queue state to hooks.
|
|
656
|
+
|
|
657
|
+
From your components, you interact through hooks:
|
|
658
|
+
|
|
659
|
+
- **`useOfflineMutation`** — defines the API handler and pushes actions to the queue (or calls the handler directly if online)
|
|
660
|
+
- **`useOfflineQueue`** — reads the current queue state without unnecessary re-renders
|
|
661
|
+
- **`useSyncProgress`** — gives you per-item progress during a sync session
|
|
662
|
+
|
|
663
|
+
When the device comes back online, the manager either flushes the queue automatically or calls your `onOnlineRestore` callback, depending on the sync mode you chose. Each queued action is resolved through its registered handler first, then falls back to the global `onSyncAction`.
|
|
664
|
+
|
|
665
|
+
Storage is abstracted behind a simple `getItem` / `setItem` / `removeItem` interface, so swapping between MMKV, AsyncStorage, Realm, or your own backend is just a config change.
|
|
666
|
+
|
|
667
|
+
## License
|
|
668
|
+
|
|
669
|
+
MIT
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
673
|
+
<p align="center">Made with ❤️ by <a href="https://www.npmjs.com/~mustafaaksoy41">Mustafa Aksoy</a></p>
|