PWA React + Vite : gérer l’état d’installation côté front

Quand on transforme une application web en PWA, on pense immédiatement au manifest, au service worker et à l’installabilité. Pourtant, dans un vrai projet front, une problématique plus subtile apparaît rapidement : comment modéliser proprement l’état d’installation d’une PWA côté interface ?
Dans mon cas, le besoin était concret : afficher un bouton Installer l’application quand le navigateur le permet, exposer un état Application installée une fois l’installation terminée, conserver cette information après rafraîchissement, puis proposer une logique de réinitialisation si l’utilisateur avait désinstallé la PWA. Le tout dans une application React + Vite, avec une architecture suffisamment robuste pour être maintenable, lisible et documentable.
Dans cet article, je montre comment j’ai géré l’état d’installation d’une Progressive Web App React + Vite à partir de beforeinstallprompt, appinstalled, localStorage et de la détection du mode standalone.

1 Le vrai problème : une PWA installable ne fournit pas automatiquement un état UI fiable
Au départ, la logique paraît simple : si l’application est installable, le navigateur expose l’événement beforeinstallprompt, donc on affiche un bouton Installer. Une fois l’installation terminée, on pourrait croire que le sujet est réglé.
En réalité, ce n’est pas suffisant. Cet événement n’est pas un véritable état applicatif. Il est volatile, dépend du contexte d’exécution, n’est pas persistant après un refresh, et son absence ne signifie pas nécessairement que l’application est déjà installée. C’est précisément à cet endroit que commence le vrai travail côté front.
beforeinstallpromptpeut être disponible, puis ne plus l’être- son absence ne veut pas dire “application installée”
- un simple refresh peut faire disparaître l’état visible
- il n’existe pas de mécanisme navigateur universel permettant de piloter directement l’UI métier
Autrement dit, le navigateur envoie des signaux, mais pas une machine d’état exploitable telle quelle par l’interface.
2 Pourquoi je n’ai pas voulu dépendre uniquement du navigateur
J’ai volontairement évité une architecture d’interface dépendante exclusivement des signaux navigateur. La raison est simple : une interface utilisateur stable ne doit pas reposer uniquement sur un signal aussi contextuel que beforeinstallprompt.
À la place, j’ai construit une logique en deux couches complémentaires :
- une couche navigateur : signaux PWA, mode standalone, prompt d’installation
- une couche front applicative : état stocké, rendu conditionnel, réinitialisation manuelle
Cette séparation est structurante pour la suite de l’implémentation : le navigateur aide à détecter certains cas, mais la responsabilité de l’UI reste côté application. C’est ce qui rend le comportement plus stable, plus lisible et plus défendable techniquement.

3 La machine d’état que j’ai retenue côté interface
Au lieu de raisonner en “prompt visible ou non”, j’ai structuré l’interface autour de trois états explicites :
- installable : l’application n’est pas marquée comme installée et le navigateur expose un prompt d’installation
- installed : l’application est considérée comme installée côté front
- fallback : l’application n’est pas marquée comme installée, mais aucun prompt n’est disponible à cet instant
Cette distinction est fondamentale. En PWA, “pas de bouton Installer” n’est pas équivalent à “application installée”. Sans cette séparation, on introduit facilement une erreur logique dans l’interface.
type PwaUiState = 'installable' | 'installed' | 'fallback';En pratique, cette modélisation évite d’associer à tort l’absence de prompt à un état installé, ce qui est l’une des erreurs les plus fréquentes sur ce type d’implémentation.
À partir de là, l’UI devient plus honnête :
- si l’état est
installable, j’affiche Installer l’application - si l’état est
installed, j’affiche Application installée - si l’état est
fallback, j’affiche un état d’attente ou de vérification
4 Pourquoi j’ai utilisé localStorage
Une fois l’installation acceptée, je voulais éviter que l’interface revienne à son état initial après un simple refresh. Pour cela, j’ai choisi de persister l’état d’installation dans localStorage.
Ce choix était volontaire pour plusieurs raisons :
- lecture immédiate au chargement
- aucune dépendance à une infrastructure serveur
- persistance locale suffisante pour un état d’interface
- intégration très simple dans React
Je ne stocke pas ici une donnée métier critique. Je stocke un signal de cohérence d’interface. C’est exactement le type de cas pour lequel localStorage est pertinent.
const PWA_INSTALLED_KEY = 'pwa-installed-state';
localStorage.setItem(PWA_INSTALLED_KEY, 'true');
const storedInstalled = localStorage.getItem(PWA_INSTALLED_KEY) === 'true';Autrement dit, localStorage n’est pas utilisé ici comme vérité système, mais comme mécanisme de persistance d’un état UI dérivé. Il s’agit d’une couche d’état front, pas d’une source absolue de vérité sur l’OS ou le navigateur.

5 Comment j’affiche le bouton “Installer l’application”
Le bouton Installer l’application n’est affiché que dans un cas précis : l’application n’est pas déjà marquée comme installée et le navigateur a fourni un événement beforeinstallprompt.
J’ai encapsulé cette logique dans un hook dédié. Cela évite d’éparpiller la logique PWA dans plusieurs composants et garde une frontière claire entre détection technique et rendu UI.
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);Ensuite, au rendu :
{uiState === 'installable' && (
<button onClick={handleInstall}>
Installer l’application
</button>
)}Le bouton “Installer” n’est donc pas un élément permanent ou décoratif : il est strictement corrélé à la disponibilité réelle du prompt d’installation côté navigateur.
6 Comment je passe l’interface en “Application installée”
Le passage en état installed se fait à partir de plusieurs signaux complémentaires :
- si l’utilisateur accepte le prompt d’installation
- si l’événement
appinstalledest déclenché - si l’application tourne déjà en mode
standalone
J’ai volontairement agrégé plusieurs signaux d’installation afin de réduire les incohérences. Le front ne dépend donc pas d’un seul événement.
const onAppInstalled = () => {
localStorage.setItem(PWA_INSTALLED_KEY, 'true');
setIsInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener('appinstalled', onAppInstalled);J’ajoute aussi une détection du mode standalone :
function detectStandaloneMode(): boolean {
const iosStandalone =
typeof window !== 'undefined' &&
typeof window.navigator.standalone === 'boolean' &&
window.navigator.standalone === true;
const displayModeStandalone =
typeof window !== 'undefined' &&
window.matchMedia('(display-mode: standalone)').matches;
return iosStandalone || displayModeStandalone;
}Une fois l’état reconnu comme installé, l’UI change :
- le bouton Installer disparaît
- un badge ou bouton désactivé Application installée apparaît
- une action de réinitialisation peut devenir disponible dans le contexte navigateur


7 Pourquoi la désinstallation est le cas le plus délicat
Le cas le plus intéressant n’était pas l’installation, mais la désinstallation. En pratique, le navigateur n’expose pas d’événement standard simple et fiable permettant de dire : “l’utilisateur vient de désinstaller la PWA, remets immédiatement l’UI dans l’état initial”.
C’est précisément ce vide qui oblige à penser l’expérience produit autrement. J’ai donc fait un choix assumé : gérer la sortie de cet état côté UX, plutôt que d’attendre une API parfaite qui n’existe pas dans les conditions dont j’avais besoin.
Concrètement, j’ai ajouté une action claire :
- Vous avez désinstallé l’application ?
- ouverture d’une modale de confirmation
- suppression de l’état local
- recalcul de l’UI
Cette logique ne prétend pas détecter magiquement l’OS. Elle offre une issue UX cohérente lorsque le stockage local dit encore “installée” alors que ce n’est potentiellement plus vrai côté utilisateur.
8 Comment j’ai implémenté la réinitialisation
La réinitialisation repose sur une opération volontairement simple :
const resetInstalledState = useCallback(() => {
localStorage.removeItem(PWA_INSTALLED_KEY);
setIsInstalled(false);
}, []);Cette fonction est appelée uniquement après confirmation de l’utilisateur dans une modale. Le but est d’éviter un reset accidentel et de conserver une interface lisible.
{showResetModal && (
<div className='modal'>
<h3>Réinitialiser l’état après désinstallation ?</h3>
<p>Cette action supprime uniquement l’état stocké localement.</p>
<button onClick={confirmReset}>Oui, réinitialiser</button>
</div>
)}Une fois l’état effacé, l’application peut revenir soit à l’état installable si le navigateur repropose le prompt, soit à l’état fallback dans le cas contraire.

9 Pourquoi je n’affiche pas le bouton de réinitialisation dans l’application installée
Au départ, j’avais ajouté l’action de réinitialisation dès que l’application était considérée comme installée côté front. En avançant dans les tests, j’ai réalisé qu’un détail d’UX et de logique technique était essentiel : ce bouton n’a de sens que dans le site web ouvert dans le navigateur, pas dans l’application installée elle-même.
Si l’utilisateur exécute déjà l’application en mode standalone, cela signifie qu’il est en train d’utiliser la version installée. Dans ce contexte, afficher une action du type “Vous avez désinstallé l’application ?” serait incohérent.
J’ai donc distingué deux contextes d’exécution :
- contexte navigateur : le site web est ouvert dans Chrome, Edge ou Safari ; l’action de réinitialisation peut être utile si l’état local affiche encore “installée” alors que l’utilisateur a désinstallé la PWA
- contexte standalone : l’application est déjà lancée comme app installée ; le bouton de réinitialisation doit être masqué
Ce choix améliore la cohérence de l’interface : le bouton de reset reste un outil de support côté web, et non une action visible dans l’application installée elle-même.
const shouldShowResetAction =
uiState === 'installed' && !isRunningStandalone;Ce point montre qu’une interface PWA robuste ne dépend pas uniquement d’un état booléen, mais aussi d’une lecture correcte du contexte réel d’exécution. Une action peut être pertinente dans le navigateur web, tout en devenant incohérente dans l’application installée.
Pour détecter ce contexte, j’ai utilisé le mode d’affichage du navigateur :
function detectStandaloneMode(): boolean {
const iosStandalone =
typeof window !== 'undefined' &&
typeof window.navigator.standalone === 'boolean' &&
window.navigator.standalone === true;
const displayModeStandalone =
typeof window !== 'undefined' &&
window.matchMedia('(display-mode: standalone)').matches;
return iosStandalone || displayModeStandalone;
}Avec cette contrainte supplémentaire, la logique devient plus propre : je peux proposer une réinitialisation là où elle a du sens, tout en évitant de polluer l’expérience dans l’app installée.



10 Le hook React que j’ai construit
Pour garder cette logique testable et réutilisable, je l’ai isolée dans un hook dédié. Cette décision est importante sur le plan architectural : la logique PWA ne doit pas polluer les composants métier du jeu.
Le hook concentre la logique d’orchestration des signaux PWA, tandis que le composant de footer reste focalisé sur le rendu. Cette séparation réduit le couplage et facilite la maintenance.
export function usePwaInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
const [isRunningStandalone, setIsRunningStandalone] = useState(false);
useEffect(() => {
const standalone = detectStandaloneMode();
const storedInstalled = localStorage.getItem(PWA_INSTALLED_KEY) === 'true';
setIsRunningStandalone(standalone);
if (standalone) {
setIsInstalled(true);
localStorage.setItem(PWA_INSTALLED_KEY, 'true');
} else {
setIsInstalled(storedInstalled);
}
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const onAppInstalled = () => {
localStorage.setItem(PWA_INSTALLED_KEY, 'true');
setIsInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);
window.addEventListener('appinstalled', onAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt as EventListener);
window.removeEventListener('appinstalled', onAppInstalled);
};
}, []);
const installApp = useCallback(async () => {
if (!deferredPrompt) return false;
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome === 'accepted') {
localStorage.setItem(PWA_INSTALLED_KEY, 'true');
setIsInstalled(true);
setDeferredPrompt(null);
return true;
}
return false;
}, [deferredPrompt]);
const resetInstalledState = useCallback(() => {
localStorage.removeItem(PWA_INSTALLED_KEY);
setIsInstalled(false);
}, []);
const uiState = isInstalled ? 'installed' : deferredPrompt ? 'installable' : 'fallback';
return {
uiState,
isRunningStandalone,
installApp,
resetInstalledState,
};
}Cette structure a plusieurs avantages :
- logique centralisée
- composants d’interface plus simples
- meilleure lisibilité du flux
- comportement réutilisable ailleurs dans l’application
11 Pourquoi j’ai placé cette logique dans le footer
J’ai volontairement exposé cette logique dans le footer de l’application. Le choix n’est pas anecdotique. Le footer me permet d’avoir une zone stable, visible et non intrusive pour gérer :
- le bouton d’installation
- l’état “application installée”
- l’action de réinitialisation
- l’état fallback de vérification
Je ne voulais pas que cette logique perturbe le gameplay principal. Le footer joue donc ici un rôle de zone utilitaire persistante, ce qui est particulièrement pertinent pour une fonctionnalité transverse comme l’installation PWA.
12 Ce que cette approche résout vraiment
Avec cette implémentation, j’obtiens un comportement beaucoup plus cohérent :
- le bouton Installer n’apparaît que lorsqu’il a un sens technique
- l’état Application installée est préservé après refresh
- l’interface ne confond plus absence de prompt et installation réelle
- la désinstallation dispose d’une sortie UX claire
- la logique reste testable, isolée et maintenable
En résumé, l’interface repose sur quatre briques : détection du prompt d’installation, persistance locale de l’état, identification du mode standalone et réinitialisation manuelle après désinstallation.
Ce que je trouve intéressant ici, c’est que la solution finale n’est ni uniquement “front”, ni uniquement “PWA”. C’est un mélange de gestion d’état, de connaissance des limites navigateur et de décision UX.
13 Ce que j’éviterais aujourd’hui
Avec le recul, il y a plusieurs approches que je déconseille pour ce cas :
- afficher “Installée” simplement parce que le bouton d’installation n’est pas visible
- piloter toute l’interface uniquement avec
beforeinstallprompt - supposer qu’un refresh reflète correctement l’état réel de la PWA
- ne prévoir aucun mécanisme de réinitialisation
- considérer
localStoragecomme une preuve absolue de présence de l’application sur l’appareil
Ces raccourcis produisent rapidement une interface incohérente. Le vrai enjeu n’est pas seulement de déclencher l’installation, mais de synchroniser correctement l’état perçu par l’utilisateur avec les signaux réellement disponibles.
14 Conclusion
Gérer l’installabilité d’une PWA ne consiste pas seulement à faire apparaître un bouton. Le vrai sujet, côté produit et côté front, est de construire une logique d’état fiable autour d’API navigateur incomplètes, contextuelles et parfois inconsistantes selon les environnements.
Dans mon implémentation, j’ai choisi une approche pragmatique : écouter les événements utiles, persister un état local, exposer une interface différenciée selon trois états explicites, masquer la réinitialisation dans le mode standalone et proposer un reset manuel pour couvrir le cas de désinstallation. Il ne s’agit pas d’une détection système parfaite, mais d’une architecture d’interface robuste face aux limites actuelles de l’écosystème PWA.
Et c’est précisément ce type de détail qui montre qu’une PWA bien pensée ne repose pas seulement sur un manifest ou un service worker, mais aussi sur une vraie compréhension de la gestion d’état côté client, du contexte d’exécution et des implications UX en production.
Besoin d’une implémentation PWA propre côté front ?
J’accompagne les projets React, Angular et front modernes sur les sujets PWA, performance web, SEO technique, GEO et architecture front, avec une approche orientée produit, code et maintenabilité.


