@lewis.devs/offline-queue
v0.1.0
Published
Persistent, ordered, retrying offline mutation queue — with an RTK Query adapter
Maintainers
Readme
@lewis.devs/offline-queue
A persistent, ordered, retrying queue for mutations made while offline — plus an adapter that plugs it into RTK Query.
The core has zero dependencies and knows nothing about React, Redux or React Native. Everything platform-specific (connectivity, storage, how to replay a task) is injected.
npm i @lewis.devs/offline-queueWhat it guarantees
- Every call is its own task. Two offline
createPostcalls are two posts. - Order is preserved. Tasks replay one at a time, in order. If task N fails, task N+1 waits — it may well depend on N having landed.
- Single-flight. Concurrent
flush()calls share one run, so a flapping connection can't send the same mutation twice. - Bounded retries. Exponential backoff,
maxAttempts, and permanent errors (HTTP 4xx except 408/429) are dropped instead of retried forever. - Real deletion. A task that leaves the queue leaves storage too.
Core
import {
createOfflineQueue,
browserNetwork,
localStorageAdapter,
} from '@lewis.devs/offline-queue';
const queue = createOfflineQueue({
network: browserNetwork(),
storage: localStorageAdapter(),
execute: async task => {
const res = await fetch(`/api/${task.name}`, {
method: 'POST',
body: JSON.stringify(task.payload),
});
if (!res.ok) {
throw {status: res.status}; // throw to fail; `status` drives permanent-vs-transient
}
},
onDrop: (task, error) => console.warn('gave up on', task.name, error),
});
queue.start(); // flush now, and on every reconnection
queue.enqueue('posts', {description: 'hello'});| Option | Default | |
|---|---|---|
| execute | — | replays a task; throw to fail |
| network | — | isOnline() must be synchronous |
| storage | memoryStorage() | asyncStorageAdapter, localStorageAdapter, or your own |
| maxAttempts | 5 | |
| backoff | 2^n s, capped at 30 s | |
| isPermanent | HTTP 4xx except 408/429 | never-retry predicate |
| onSent / onDrop | — | task left the queue, successfully or not |
Built-in adapters (netInfoNetwork, asyncStorageAdapter) take their dependency as
an argument, so this package never imports react-native:
import NetInfo from '@react-native-community/netinfo';
import AsyncStorage from '@react-native-async-storage/async-storage';
import {netInfoNetwork, asyncStorageAdapter} from '@lewis.devs/offline-queue';
const network = netInfoNetwork(NetInfo);
const storage = asyncStorageAdapter(AsyncStorage);
netInfoNetworkstarts optimistic on purpose. NetInfo reportsisInternetReachable: nulluntil its first probe lands, and treating that unknown state as "offline" queues perfectly good requests at cold start.
RTK Query adapter
import {
createOfflineMiddleware,
createRtkExecutor,
revertOptimistic,
forgetOptimistic,
} from '@lewis.devs/offline-queue/rtk-query';
const queue = createOfflineQueue({
network,
storage,
execute: createRtkExecutor(
{dispatch: a => store.dispatch(a), getState: () => store.getState()},
api,
),
onSent: forgetOptimistic,
onDrop: task => revertOptimistic(task),
});
const store = configureStore({
reducer,
middleware: gdm => [
...gdm(),
api.middleware,
createOfflineMiddleware({
queue,
// Opt-in, by endpoint name. Anything not listed here fails normally offline.
endpoints: {
postPosts: {
optimistic: (args, store, task) =>
store.dispatch(
api.util.updateQueryData('getPosts', FEED_ARGS, draft => {
draft.data.unshift({id: task.id, ...args, __pending: true});
}),
),
},
},
onQueued: task => toast(`"${task.name}" will be sent when you're back online`),
}),
],
});Then call queue.start() once at the app root — not inside a screen, or nothing
syncs while that screen is unmounted.
The optimistic row goes through RTK Query's own cache, so your components stay oblivious:
const {data} = useGetPostsQuery(FEED_ARGS); // pending items are already in here
optimisticcannot be your only path to the screen.api.util.updateQueryDatapatches an existing cache entry. If the query has never succeeded — offline cold start, backend down — there is nothing to patch and it silently does nothing, so the pending item never appears. That is exactly the moment the user most needs to see it.
For anything where "nothing appeared" is the worse failure, render the queue itself. It is the source of truth, and it always has the answer:
const pending = useOfflineQueue(queue);
const rows = [
...pending.map(task => ({key: task.id, isPending: true, ...toRow(task.payload)})),
...(data?.data ?? []).map(post => ({key: String(post.id), isPending: false, ...post})),
];useOfflineQueue also covers the smaller cases — a "3 pending" badge, a dedicated
outbox screen — and gives you the task id, which is what a Cancel button needs:
<Button onPress={() => queue.remove(task.id)}>Cancel</Button>Notes
- The middleware is synchronous and lets the action through. It never awaits, and
it never swallows the mutation — swallowing would leave
useMutation()'s promise unsettled and the component stuck inisLoading. The request fails offline as it normally would; the queue replays it later. meta.argis the only RTK Query internal this package reads, and only inreadMutationArgs.- Requires RTK
>=1.9.
React Native
Metro 0.72 ignores "exports". The package ships a rtk-query/package.json stub for
that, so @lewis.devs/offline-queue/rtk-query resolves on both old and new resolvers.
Tests
npm run test:coverage # gated at 100% lines/branches/functionsThe RTK Query adapter runs its whole suite twice, against @reduxjs/toolkit 1.9
and 2.x — the two ends of the advertised peer range — because meta.arg is an
internal and a version bump is exactly how it would break.
Not handled
FormData/ files. Payloads areJSON.stringifyd. An image upload will not survive the queue.- Offline reads. This queues writes. Persisting the RTK Query cache for reads is
a separate concern (
redux-persiston the api reducer).
Contributing
Bug reports and pull requests are welcome. Start with CONTRIBUTING.md — in particular the Invariants section, which lists the rules the queue exists to enforce and why each one is there.
License
MIT © Lewis ALLEDJI
