Picture this: your user is on a train, editing a doc. Their signal drops. They save. Nothing happens. They reload. Their changes are gone.
This happens because most web apps are secretly online-only — they just pretend otherwise.
After one too many production incidents where a user lost data, I decided to fix this properly and built connectivity-js — a declarative, type-safe, offline-first connectivity layer for JavaScript and React.
The problem with ad-hoc connectivity handling
The typical pattern:
function SaveButton({ data }) {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const up = () => setIsOnline(true);
const down = () => setIsOnline(false);
window.addEventListener("online", up);
window.addEventListener("offline", down);
return () => {
window.removeEventListener("online", up);
window.removeEventListener("offline", down);
};
}, []);
const handleSave = async () => {
if (!isOnline) { alert("You're offline"); return; }
try {
await api.save(data);
} catch (err) {
// Retry? Backoff? Dedup? What if offline mid-retry?
}
};
return <button onClick={handleSave}>Save</button>;
}
Every component that touches the network ends up doing some version of this. Over time:
- Offline UI is inconsistent — some components block, some silently drop
- Retry logic is duplicated everywhere or missing entirely
- No deduplication — rapid clicks send redundant requests
-
navigator.onLinelies (returnstrueeven when the network is unreachable)
The solution: three primitives
Primitive 1: <Connectivity> — declarative offline UI
import { Connectivity } from '@connectivity-js/react';
<Connectivity fallback={<OfflineScreen />} delayMs={2_000}>
<App />
</Connectivity>
Renders children online, fallback offline. delayMs prevents flicker — the fallback only shows after 2 seconds of confirmed offline. SSR-safe: unknown is treated as online to avoid hydration mismatch.
You can scope it per-section:
function Dashboard() {
return (
<div>
<StaticContent />
<Connectivity fallback={<p>This section requires connectivity.</p>}>
<LiveData />
</Connectivity>
</div>
);
}
Primitive 2: useConnectivity and useOnConnectivityChange — reactive state
function StatusBadge() {
const { status, quality } = useConnectivity();
// status: 'online' | 'offline' | 'unknown'
// quality: { rttMs, effectiveType, downlink }
if (status === 'offline') return <Badge color="red">Offline</Badge>;
if (quality.rttMs !== undefined && quality.rttMs > 500) {
return <Badge color="yellow">Slow</Badge>;
}
return <Badge color="green">Online</Badge>;
}
For transition events, use useOnConnectivityChange:
useOnConnectivityChange({
offline: () => toast.warning('Connection lost'),
online: (transition) => {
if (transition.duration > 60_000) {
dialog.open(ReconnectedNotice); // been offline a while
return;
}
toast.success('Back online');
},
});
Inline callbacks are safe — stored in a ref internally, no re-subscription. Only fires on transitions, not mount.
Primitive 3: useAction — actions that survive offline
const { execute, pendingCount } = useAction({
actionKey: 'save',
request: (input: { id: string; data: string }) => api.save(input),
dedupeKey: (input) => input.id,
whenOffline: 'queue',
retry: { maxAttempts: 3, backoffMs: (n) => 2 ** n * 500 },
}, {
onSuccess: () => toast.success('Saved!'),
onEnqueued: () => toast.info('Offline — queued for sync'),
});
Deduplication: auto-save on every keystroke + offline = 50+ requests queued. With dedupeKey, the server gets one — the final state.
execute({ id: '1', data: 'v1' }) → queue: [v1]
execute({ id: '1', data: 'v2' }) → queue: [v2] ← v1 replaced
execute({ id: '1', data: 'v3' }) → queue: [v3] ← v2 replaced
reconnect → server receives v3 only
Grace period: brief WiFi handoffs (1-2s) are invisible to users.
<ConnectivityProvider
detectors={[browserOnlineDetector()]}
gracePeriodMs={3_000}
>
Full TypeScript inference — no manual annotations:
const result = await client.execute(saveAction, { id: '1', data: 'hello' });
if (!result.enqueued) {
result.result; // typed as the return of your request fn ✓
}
Framework-agnostic
The core (@connectivity-js/core) has zero framework dependencies. You can use it with Vue, Svelte, vanilla JS, or any other environment:
import { getConnectivityClient, browserOnlineDetector } from '@connectivity-js/core';
const client = getConnectivityClient({
detectors: [browserOnlineDetector()],
gracePeriodMs: 3_000,
});
client.start();
client.subscribe((state, transition) => {
if (transition?.to === 'offline') showOfflineBanner();
if (transition?.to === 'online') hideOfflineBanner();
});
React adapter is a thin layer on top. More framework adapters are planned.
Packages
| Package | Description |
|---|---|
@connectivity-js/core |
Framework-agnostic core. Zero deps. Works with any framework or vanilla JS. |
@connectivity-js/react |
React adapter. Hooks + components. Includes core — no separate install. |
@connectivity-js/devtools |
Framework-agnostic DevTools panel. Mounts into any DOM element. |
@connectivity-js/react-devtools |
React DevTools component. Real-time job queue visualization. |
npm install @connectivity-js/react
If you found this useful, a star on GitHub would mean a lot — it helps others discover the project.
Would love to hear what offline scenarios you've dealt with in production.

Top comments (0)