Skip to content

React-Specific PWA Patterns

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.

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>
);
}

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>
);
}

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.

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.

OptionBenefitCost
Bind PWA state (installable, update available) to React state/hooksUI reacts to the service worker lifecycle immediately, written declarativelyWatch 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 casesYou sync state back into React by hand yourself, adding a place for bugs
  • 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 useEffect cleanup 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.

Which browser event fires before the install prompt is shown to the user?
Why should useOnlineStatus guard navigator.onLine with a typeof check?
Which package is recommended for adding PWA support to a Next.js App Router project?
What should you do after the user responds to the install prompt?