DEV Community

Cover image for Offline-first React without the boilerplate — how I built connectivity-js
connectivity-js
connectivity-js

Posted on

Offline-first React without the boilerplate — how I built connectivity-js

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>;
}
Enter fullscreen mode Exit fullscreen mode

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.onLine lies (returns true even 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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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');
  },
});
Enter fullscreen mode Exit fullscreen mode

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'),
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Grace period: brief WiFi handoffs (1-2s) are invisible to users.

<ConnectivityProvider
  detectors={[browserOnlineDetector()]}
  gracePeriodMs={3_000}
>
Enter fullscreen mode Exit fullscreen mode

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 ✓
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)