React-Specific PWA Patterns
Introduction
Section titled “Introduction”Beyond the update prompt, React PWAs benefit from two utility hooks: one for network status and one for the install prompt. These hooks encapsulate browser APIs inside a clean React abstraction so components remain declarative.
useOnlineStatus hook
Section titled “useOnlineStatus hook”The useOnlineStatus hook reads navigator.onLine at mount time and keeps the value in sync by listening to the online and offline events on window. A typeof navigator guard makes the hook safe in SSR environments where window is absent.
import { useState, useEffect } from 'react';
export function useOnlineStatus(): { isOnline: boolean } { const [isOnline, setIsOnline] = useState<boolean>( typeof navigator !== 'undefined' ? navigator.onLine : true );
useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); }
window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline);
return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []);
return { isOnline };}Usage example:
import { useOnlineStatus } from './useOnlineStatus';
export function NetworkBanner() { const { isOnline } = useOnlineStatus();
return ( <div className={isOnline ? 'banner banner--online' : 'banner banner--offline'}> {isOnline ? 'Online' : 'Offline'} </div> );}useInstallPrompt hook
Section titled “useInstallPrompt hook”The browser fires a beforeinstallprompt event before showing the native install UI. Storing that event in a useRef lets you trigger the prompt from a button at any time. After the user responds, clear the ref so the button no longer appears.
import { useState, useEffect, useRef } from 'react';
interface BeforeInstallPromptEvent extends Event { prompt(): Promise<void>; readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;}
export function useInstallPrompt(): { isInstallable: boolean; promptInstall: () => void;} { const deferredPrompt = useRef<BeforeInstallPromptEvent | null>(null); const [isInstallable, setIsInstallable] = useState(false);
useEffect(() => { function handleBeforeInstallPrompt(e: Event) { e.preventDefault(); deferredPrompt.current = e as BeforeInstallPromptEvent; setIsInstallable(true); }
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); }; }, []);
async function promptInstall() { if (!deferredPrompt.current) return; await deferredPrompt.current.prompt(); await deferredPrompt.current.userChoice; deferredPrompt.current = null; setIsInstallable(false); }
return { isInstallable, promptInstall };}Usage example:
import { useInstallPrompt } from './useInstallPrompt';
export function InstallButton() { const { isInstallable, promptInstall } = useInstallPrompt();
if (!isInstallable) return null;
return ( <button onClick={promptInstall}> Install app </button> );}CRA is deprecated
Section titled “CRA is deprecated”Create React App is no longer actively maintained. For new React PWAs, use Vite with vite-plugin-pwa — it is faster, more configurable, and actively developed. If your project requires SSR or a full-stack React solution, use Next.js with a Workbox-based package such as @ducanh2912/next-pwa or serwist.
Next.js note
Section titled “Next.js note”For Next.js App Router projects, the virtual:pwa-register/react hook is Vite-only and does not apply. The recommended approach is the @ducanh2912/next-pwa package, which is built on Workbox and handles the App Router’s server-component model, or the serwist library which provides a framework-agnostic Workbox wrapper with first-class Next.js support. Both generate a service worker at build time and integrate with the Next.js build pipeline.
Trade-offs
Section titled “Trade-offs”| Option | Benefit | Cost |
|---|---|---|
| Bind PWA state (installable, update available) to React state/hooks | UI reacts to the service worker lifecycle immediately, written declaratively | Watch for unnecessary re-renders if the hook is not designed carefully |
| Manage the service worker separately from the React tree (vanilla JS listeners) | Not tied to React’s re-render cycle at all, simpler for trivial cases | You sync state back into React by hand yourself, adding a place for bugs |
Common mistakes
Section titled “Common mistakes”- Calling service worker logic (register, event listeners) directly inside the render function instead of inside
useEffect, so it runs again on every re-render. - Not cleaning up event listeners in the
useEffectcleanup function, stacking up duplicate listeners every time the component remounts. - Keeping PWA install-prompt state in local component state instead of a global store, so the install button falls out of sync across multiple components.
The Vite PWA ecosystem, as used by Elk (the open-source Mastodon client), wraps virtual:pwa-register in a hook so the update-available UI can stay fully declarative, and Squoosh — another open-source PWA from Google Chrome Labs — ties its service worker lifecycle into UI state to tell users when a new version is ready.