Swoff Swoff

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: () => void

The 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 } | null

Generated 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: boolean

Generated 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: boolean

Usage:

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 generate to generate hooks for your project, then read swoff/GUIDE.md

On this page