Hooks
React hooks for Swoff's service worker system.
React Hooks
These hooks listen to window events dispatched by the client registration code and expose reactive state for your components.
All hooks are auto-generated by npx @swoff/cli generate into swoff/hooks/ when your framework is "react". The full integration guide is at swoff/GUIDE.md.
useSWUpdate
Service worker update lifecycle management.
const {
updateStatus, // 'idle' | 'available' | 'downloading' | 'ready'
currentVersion, // string | null
availableVersion, // string | null
progress, // number (0-100)
forceUpdate, // boolean
error, // string | null
acceptUpdate, // () => Promise<void>
dismissUpdate, // () => void
} = useSWUpdate();Usage:
function App() {
const { updateStatus, progress, acceptUpdate, dismissUpdate } = useSWUpdate();
if (updateStatus === 'idle') return <MainApp />;
return (
<div>
<p>Update v{availableVersion} available</p>
{updateStatus === 'downloading' ? (
<progress value={progress} max={100} />
) : (
<>
<button onClick={acceptUpdate}>Update</button>
<button onClick={dismissUpdate}>Later</button>
</>
)}
</div>
);
}Implementation:
import { useState, useEffect, useCallback } from "react";
import { handleUpdateApproved } from "../sw/injector";
export function useSWUpdate() {
const [state, setState] = useState({
updateStatus: "idle" as "idle" | "available" | "downloading" | "ready",
currentVersion: (window.currentSWVersion as string | undefined) || null,
availableVersion: null as string | null,
progress: 0 as number,
forceUpdate: false,
error: null as string | null,
});
useEffect(() => {
if (sessionStorage.getItem("sw-dismissed-update") === "true") return;
const onAvailable = (e: WindowEventMap["sw-update-available"]) =>
setState((s) => ({
...s,
updateStatus: "available",
availableVersion: e.detail.version,
forceUpdate: window.swUpdateRequired || false,
}));
const onProgress = (e: WindowEventMap["sw-progress"]) =>
setState((s) => ({ ...s, updateStatus: "downloading", progress: e.detail.percent }));
const onReady = () =>
setState((s) => ({ ...s, updateStatus: "idle", progress: 0 }));
const onError = () =>
setState((s) => ({ ...s, error: "SW registration failed" }));
window.addEventListener("sw-update-available", onAvailable);
window.addEventListener("sw-progress", onProgress);
window.addEventListener("sw-ready", onReady);
window.addEventListener("sw-error", onError);
return () => {
window.removeEventListener("sw-update-available", onAvailable);
window.removeEventListener("sw-progress", onProgress);
window.removeEventListener("sw-ready", onReady);
window.removeEventListener("sw-error", onError);
};
}, []);
const acceptUpdate = useCallback(async () => {
if (!state.availableVersion) return;
await handleUpdateApproved(state.availableVersion);
}, [state.availableVersion]);
const dismissUpdate = useCallback(() => {
sessionStorage.setItem("sw-dismissed-update", "true");
setState((s) => ({ ...s, updateStatus: "idle" }));
}, []);
return { ...state, acceptUpdate, dismissUpdate };
}useSWProgress
SW download progress indicator. A lighter alternative to useSWUpdate when you only need a progress bar.
const { status, progress } = useSWProgress();
// status: 'idle' | 'installing'
// progress: number (0-100)Usage:
function ProgressBar() {
const { status, progress } = useSWProgress();
if (status !== "installing") return null;
return <div className="h-1 bg-blue-500" style={{ width: `${progress}%` }} />;
}Implementation:
import { useState, useEffect } from "react";
export function useSWProgress() {
const [state, setState] = useState({
status: "idle" as "idle" | "installing",
progress: 0 as number,
});
useEffect(() => {
const onProgress = (e: WindowEventMap["sw-progress"]) =>
setState({ status: "installing", progress: e.detail.percent });
const onReady = () => setState({ status: "idle", progress: 0 });
window.addEventListener("sw-progress", onProgress);
window.addEventListener("sw-ready", onReady);
return () => {
window.removeEventListener("sw-progress", onProgress);
window.removeEventListener("sw-ready", onReady);
};
}, []);
return state;
}useCachedFetch
Fetch data with auto-parsed JSON and automatic re-fetching on cache invalidation.
const { data, error, loading, refetch } = useCachedFetch<T>(url, options?);
// data: T | null — parsed JSON response
// error: Error | null
// loading: boolean
// refetch: () => voidThe options type is the same as fetchWithCache: RequestInit plus tags, auth, queueOffline, invalidate, type, strategy, staleWhileRevalidate.
Usage:
function Todos() {
const { data, loading, refetch } = useCachedFetch<Todo[]>("/api/todos");
if (loading) return <Spinner />;
return (
<>
<TodoList data={data} />
<button onClick={refetch}>Refresh</button>
</>
);
}The hook listens for cache-invalidated events and automatically re-fetches when the event's tags match the URL.
Implementation:
import { useState, useEffect, useCallback, useRef, startTransition } from "react";
import { fetchWithCache } from "../fetch-wrapper";
import { generateTags } from "../invalidation-tags";
export function useCachedFetch<T = any>(
url: string,
options: Parameters<typeof fetchWithCache>[1] = {},
) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const [refetchCount, setRefetchCount] = useState(0);
const optionsRef = useRef(options);
useEffect(() => { optionsRef.current = options; }, [options]);
const refetch = useCallback(() => setRefetchCount((c) => c + 1), []);
useEffect(() => {
let cancelled = false;
startTransition(() => setLoading(true));
const doFetch = async () => {
try {
const { response } = await fetchWithCache(url, optionsRef.current);
if (cancelled) return;
setData(response ? await response.json() : null);
if (!cancelled) setError(null);
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err : new Error(String(err)));
} finally {
if (!cancelled) setLoading(false);
}
};
doFetch();
return () => { cancelled = true; };
}, [url, refetchCount]);
useEffect(() => {
const onInvalidated = (e: Event) => {
const detail = (e as CustomEvent).detail;
const tags = detail?.tags;
if (!tags || !Array.isArray(tags)) return;
const urlTags = generateTags(url);
if (tags.some((t: string) => urlTags.includes(t)))
setRefetchCount((c) => c + 1);
};
window.addEventListener("cache-invalidated", onInvalidated);
return () => window.removeEventListener("cache-invalidated", onInvalidated);
}, [url]);
return { data, error, loading, refetch };
}useMutationQueue
Offline mutation queue state.
const { pending, lastSync } = useMutationQueue();
// pending: number — mutations waiting to sync
// lastSync: { succeeded: number, failed: number } | nullGenerated when features.mutationQueue is enabled.
Usage:
function SyncIndicator() {
const { pending, lastSync } = useMutationQueue();
return (
<>
{pending > 0 && <span>{pending} pending</span>}
{lastSync?.failed > 0 && <span>{lastSync.failed} failed</span>}
</>
);
}Implementation:
import { useState, useEffect } from "react";
import { getPendingCount } from "../mutation-queue";
export function useMutationQueue() {
const [state, setState] = useState<{
pending: number;
lastSync: { succeeded: number; failed: number } | null;
}>({ pending: 0, lastSync: null });
useEffect(() => {
getPendingCount().then((count) => setState((s) => ({ ...s, pending: count })));
const onSync = (e: CustomEvent) => {
getPendingCount().then((count) =>
setState({
pending: count,
lastSync: { succeeded: e.detail.succeeded, failed: e.detail.failed },
}),
);
};
const onChange = () => {
getPendingCount().then((count) => setState((s) => ({ ...s, pending: count })));
};
window.addEventListener("mutation-sync-complete", onSync);
window.addEventListener("mutation-queue-changed", onChange);
return () => {
window.removeEventListener("mutation-sync-complete", onSync);
window.removeEventListener("mutation-queue-changed", onChange);
};
}, []);
return state;
}useAuth
Reactive authentication and connectivity state.
const { authenticated, user, online } = useAuth();
// authenticated: boolean
// user: Record<string, unknown> | null
// online: booleanGenerated when features.auth.enabled is enabled. Listens to online/offline events and sw-auth-state-change.
Usage:
function Profile() {
const { authenticated, user, online } = useAuth();
if (!authenticated) return <LoginPage />;
return (
<div>
Welcome {user?.name}
{!online && <span> (offline)</span>}
</div>
);
}Implementation:
import { useState, useEffect } from "react";
import { getAuthState } from "../auth/state";
export function useAuth() {
const [state, setState] = useState({
authenticated: false,
user: null as Record<string, unknown> | null,
online: navigator.onLine,
});
useEffect(() => {
getAuthState().then(setState);
const onOnline = () => setState((s) => ({ ...s, online: true }));
const onOffline = () => setState((s) => ({ ...s, online: false }));
const onAuthChange = () => getAuthState().then(setState);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
window.addEventListener("sw-auth-state-change", onAuthChange);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
window.removeEventListener("sw-auth-state-change", onAuthChange);
};
}, []);
return state;
}usePushSubscription
Push notification subscription management.
const { subscribed, subscription, permission, loading, subscribe, unsubscribe } =
usePushSubscription(vapidPublicKey);
// subscribed: boolean
// subscription: PushSubscription | null
// permission: NotificationPermission
// loading: boolean
// subscribe: () => Promise<boolean>
// unsubscribe: () => Promise<void>Generated when features.pushNotifications.enabled is enabled.
Usage:
function NotificationsToggle({ vapidKey }: { vapidKey: string }) {
const { subscribed, permission, subscribe, unsubscribe } = usePushSubscription(vapidKey);
if (permission === "denied") return <p>Notifications blocked</p>;
return (
<button onClick={subscribed ? unsubscribe : subscribe}>
{subscribed ? "Unsubscribe" : "Subscribe"}
</button>
);
}Implementation:
import { useState, useEffect, useCallback } from "react";
import { subscribeToPush, unsubscribeFromPush, isSubscribed, getPushSubscription } from "../push";
export function usePushSubscription(vapidPublicKey: string) {
const [state, setState] = useState({
subscribed: false,
subscription: null as PushSubscription | null,
permission: Notification.permission,
loading: true,
});
useEffect(() => {
let cancelled = false;
isSubscribed().then((subscribed) => {
if (cancelled) return;
if (subscribed) {
getPushSubscription().then((sub) => {
if (cancelled) return;
setState({ subscribed: true, subscription: sub, permission: Notification.permission, loading: false });
});
} else {
setState({ subscribed: false, subscription: null, permission: Notification.permission, loading: false });
}
});
const onSubChanged = (e: CustomEvent) => {
if (cancelled) return;
if (!e.detail.subscribed) {
setState({ subscribed: false, subscription: null, permission: Notification.permission, loading: false });
} else {
getPushSubscription().then((sub) => {
if (cancelled) return;
setState({ subscribed: true, subscription: sub, permission: Notification.permission, loading: false });
});
}
};
const onPermissionChanged = (e: CustomEvent) => {
if (cancelled) return;
setState((s) => ({ ...s, permission: e.detail.permission }));
};
window.addEventListener("push-subscription-changed", onSubChanged);
window.addEventListener("push-permission-changed", onPermissionChanged);
return () => {
cancelled = true;
window.removeEventListener("push-subscription-changed", onSubChanged);
window.removeEventListener("push-permission-changed", onPermissionChanged);
};
}, []);
const subscribe = useCallback(async () => {
const sub = await subscribeToPush(vapidPublicKey);
return sub !== null;
}, [vapidPublicKey]);
const unsubscribe = useCallback(async () => {
await unsubscribeFromPush();
}, []);
return { ...state, subscribe, unsubscribe };
}useNetworkStatus
Reactive online/offline connectivity state. Always generated.
const isOnline = useNetworkStatus();
// isOnline: booleanUsage:
function OfflineBanner() {
const isOnline = useNetworkStatus();
if (isOnline) return null;
return <div className="banner">You're offline — changes will sync when connected</div>;
}Implementation:
import { useState, useEffect } from "react";
export function useNetworkStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const onOnline = () => setOnline(true);
const onOffline = () => setOnline(false);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, []);
return online;
}useBackgroundSync
Background sync registration and status.
const { supported, registered, lastSync, triggerSync } = useBackgroundSync();
// supported: boolean — browser supports Background Sync API
// registered: boolean — sync event has been registered
// lastSync: { succeeded: number, failed: number } | null
// triggerSync: () => Promise<void>Generated when features.backgroundSync is enabled. Requires mutationQueue to also be enabled.
Usage:
function SyncPanel() {
const { supported, registered, triggerSync } = useBackgroundSync();
if (!supported) return <p>Background sync not available in this browser</p>;
return <button onClick={triggerSync}>Sync Now</button>;
}Implementation:
import { useState, useEffect, useCallback } from "react";
import { retrySync } from "../background-sync";
export function useBackgroundSync() {
const [state, setState] = useState({
supported: "serviceWorker" in navigator && "SyncManager" in window,
registered: false,
lastSync: null as { succeeded: number; failed: number } | null,
});
useEffect(() => {
const onSyncComplete = (e: CustomEvent) => {
setState((s) => ({
...s,
registered: true,
lastSync: { succeeded: e.detail.succeeded, failed: e.detail.failed },
}));
};
window.addEventListener("background-sync-complete", onSyncComplete);
return () => window.removeEventListener("background-sync-complete", onSyncComplete);
}, []);
const triggerSync = useCallback(async () => {
await retrySync();
}, []);
return { ...state, triggerSync };
}useCacheInvalidation
Stable callback wrappers around cache invalidation functions.
const { invalidateByTag, invalidateByTags, invalidateUrl } = useCacheInvalidation();
// invalidateByTag: (tag: string) => Promise<void>
// invalidateByTags: (tags: string[]) => Promise<void>
// invalidateUrl: (url: string) => Promise<void>Generated when features.tagInvalidation is enabled. Each function is wrapped in useCallback for stable references.
Usage:
function TodoActions() {
const { invalidateByTag, invalidateUrl } = useCacheInvalidation();
const handleDelete = async (id: string) => {
await fetchWithCache(`/api/todos/${id}`, { method: "DELETE" });
invalidateByTag("todos");
invalidateUrl("/api/todos");
};
return <button onClick={() => handleDelete("123")}>Delete</button>;
}Implementation:
import { useCallback } from "react";
import { invalidateByTag, invalidateByTags } from "../cache";
import { invalidateUrl } from "../invalidation-tags";
export function useCacheInvalidation() {
const invalidateTag = useCallback(async (tag: string) => {
await invalidateByTag(tag);
}, []);
const invalidateTags = useCallback(async (tags: string[]) => {
await invalidateByTags(tags);
}, []);
const invalidateCacheUrl = useCallback(async (url: string) => {
await invalidateUrl(url);
}, []);
return {
invalidateByTag: invalidateTag,
invalidateByTags: invalidateTags,
invalidateUrl: invalidateCacheUrl,
};
}Next Steps
- See React Components — UI components using these hooks
- Understand the Adapter Concept — how hooks bridge window events to React
- Run
npx @swoff/cli generateto generate hooks for your project, then readswoff/GUIDE.md
Swoff