May 13, 2020
So you’ve got your service worker, your manifest.json and your app works offline, but how do you get your users to install the app?
This post will show you how to prompt your users to install your progressive web app (PWA) using React Hooks. I’m going to assume you already have all the necessary stuff for a PWA in place (manifest.json, a service worker, all served from https - when you’re not on localhost).
I’ve been creating an app and, instead of messing around with the App Store, React Native and two build pipelines, I decided to build a PWA so I could run one, web-based codebase on all devices. Users should be able to “install” the app if they wanted. So doing some research, I was surprised to find PWA support is still not universal or consistent across Android and iOS. So I created a cross-platform solution to prompt users based on their device. This solution uses React Hooks to be able to reuse and easily integrate this functionality into components.
The first bit of functionality is to store when a user has been asked to install, so we can check if they’ve been asked recently and therefore not show the prompt too often. This is common to both iOS and Android prompts, so I extracted it into a Hook of its own.
import { useState } from 'react';
import moment from 'moment';
const getInstallPromptLastSeenAt = (promptName: string): string => localStorage.getItem(promptName);
const setInstallPromptSeenToday = (promptName: string): void => {
const today = moment().toISOString();
localStorage.setItem(promptName, today);
};
function getUserShouldBePromptedToInstall(promptName: string, daysToWaitBeforePromptingAgain: number): boolean {
const lastPrompt = moment(getInstallPromptLastSeenAt(promptName));
const daysSinceLastPrompt = moment().diff(lastPrompt, 'days');
return isNaN(daysSinceLastPrompt) || daysSinceLastPrompt > daysToWaitBeforePromptingAgain;
}
const useShouldShowPrompt = (promptName: string, daysToWaitBeforePromptingAgain = 30): [boolean, () => void] => {
const [userShouldBePromptedToInstall, setUserShouldBePromptedToInstall] = useState(
getUserShouldBePromptedToInstall(promptName, daysToWaitBeforePromptingAgain)
);
const handleUserSeeingInstallPrompt = () => {
setUserShouldBePromptedToInstall(false);
setInstallPromptSeenToday(promptName);
};
return [userShouldBePromptedToInstall, handleUserSeeingInstallPrompt];
};
export default useShouldShowPrompt;
This uses local storage to persist a user’s response across sessions. The useState
hook is used to make sure the application has a way to check the state of a user’s response. Combining these means you have a persistent way to watch for updates.
The iOS version of detecting whether a user should be prompted is simply detecting whether they’re on an iOS device, and that they haven’t already “installed” the PWA.
import useShouldShowPrompt from 'app/shared/hooks/useShouldShowPrompt';
const iosInstallPromptedAt = 'iosInstallPromptedAt';
const isIOS = (): boolean => {
// @ts-ignore
if (navigator.standalone) {
//user has already installed the app
return false;
}
const ua = window.navigator.userAgent;
const isIPad = !!ua.match(/iPad/i);
const isIPhone = !!ua.match(/iPhone/i);
return isIPad || isIPhone;
};
const useIosInstallPrompt = (): [boolean, () => void] => {
const [userShouldBePromptedToInstall, handleUserSeeingInstallPrompt] = useShouldShowPrompt(iosInstallPromptedAt);
return [isIOS() && userShouldBePromptedToInstall, handleUserSeeingInstallPrompt];
};
export default useIosInstallPrompt;
We return a hook which combines checking if the device is using iOS and whether the user has already been prompted, with a function to handle the user dismissing the prompt.
On all other platforms, PWA support is more consistent and uses web events. The key is to attach an event handler in a useEffect
hook (using the clean up variation to remove the event handler) to catch and store the install prompt event. We also use the useState
hook to store the event, and the hook we previously created useShouldShowPrompt
. This hook returns the event, a method to handle a user wanting to install and a method to handle a user declining an install. You’ll notice the useEffect
has a dependency on userShouldBePromptedToInstall
so that it will run again when that changes, this is so that the user isn’t re-prompted right after they decline to install on the native prompt.
import { useState, useEffect } from 'react';
import useShouldShowPrompt from 'app/shared/hooks/useShouldShowPrompt';
const webInstallPromptedAt = 'webInstallPromptedAt';
const useWebInstallPrompt = (): [any, () => void, () => void] => {
const [installPromptEvent, setInstallPromptEvent] = useState();
const [userShouldBePromptedToInstall, handleUserSeeingInstallPrompt] = useShouldShowPrompt(webInstallPromptedAt);
useEffect(() => {
const beforeInstallPromptHandler = event => {
event.preventDefault();
// check if user has already been asked
if (userShouldBePromptedToInstall) {
// store the event for later use
setInstallPromptEvent(event);
}
};
window.addEventListener('beforeinstallprompt', beforeInstallPromptHandler);
return () => window.removeEventListener('beforeinstallprompt', beforeInstallPromptHandler);
}, [userShouldBePromptedToInstall]);
const handleInstallDeclined = () => {
handleUserSeeingInstallPrompt();
setInstallPromptEvent(null);
};
const handleInstallAccepted = () => {
// show native prompt
installPromptEvent.prompt();
// decide what to do after the user chooses
installPromptEvent.userChoice.then(choice => {
// if the user declined, we don't want to show the prompt again
if (choice.outcome !== 'accepted') {
handleUserSeeingInstallPrompt();
}
setInstallPromptEvent(null);
});
};
return [installPromptEvent, handleInstallDeclined, handleInstallAccepted];
};
export default useWebInstallPrompt;
This is an example of how I use these two hooks in a modal asking the user if they want to install the app. This is using Reactstrap. The modal is always open because, if neither of the hooks return true, this component will return null
. If the iosInstallPrompt
is true then we show an instruction to add the web page to the Home Screen. The handleIOSInstallDeclined
is wired up to the onClick
of the “close” button to make sure the user won’t be shown it again once they dismiss the modal.
Otherwise, if webInstallPrompt
exists the modal shows a modal with an “install” or “close” button. The handleWebInstallDeclined
and handleWebInstallAccepted
are wired up to the “close” and “install” buttons to either show the native install popup or register that the user has dismissed the modal and shouldn’t be shown it again.
This is how the component’s code looks:
import React from 'react';
import { Button, Modal, Card, CardText, CardBody, CardTitle } from 'reactstrap';
import useIosInstallPrompt from 'app/shared/hooks/useIosInstallPrompt';
import useWebInstallPrompt from 'app/shared/hooks/useWebInstallPrompt';
export const InstallPWA = () => {
const [iosInstallPrompt, handleIOSInstallDeclined] = useIosInstallPrompt();
const [webInstallPrompt, handleWebInstallDeclined, handleWebInstallAccepted] = useWebInstallPrompt();
if (!iosInstallPrompt && !webInstallPrompt) {
return null;
}
return (
<Modal isOpen centered>
<Card>
<img
className="mx-auto"
style={{
borderTopRightRadius: '50%',
borderTopLeftRadius: '50%',
backgroundColor: '#fff',
marginTop: '-50px'
}}
width="100px"
src="content/images/appIcon-transparent.png"
alt="Icon"
/>
<CardBody>
<CardTitle className="text-center">
<h3>Install App</h3>
</CardTitle>
{iosInstallPrompt && (
<>
<CardText className="text-center">
Tap
<img
src="content/images/Navigation_Action_2x.png"
style={{ margin: 'auto 8px 8px' }}
className=""
alt="Add to homescreen"
width="20"
/>
then "Add to Home Screen"
</CardText>
<div className="d-flex justify-content-center">
<Button onClick={handleIOSInstallDeclined}>Close</Button>
</div>
</>
)}
{webInstallPrompt && (
<div className="d-flex justify-content-around">
<Button color="primary" onClick={handleWebInstallAccepted}>
Install
</Button>
<Button onClick={handleWebInstallDeclined}>Close</Button>
</div>
)}
</CardBody>
</Card>
</Modal>
);
};
You can find the iOS share icon in the Apple documentation or https://github.com/chrisdancee/react-ios-pwa-prompt has an svg version.
I’m happy with how this has turned out: cross platform and easy to include in my app. The use of hooks here allowed me to extract some common functionality really easily, e.g. the useShouldShowPrompt
hook, which was used in both iOS and web prompts hooks.
My code was inspired by https://jason.codes/2019/03/pwa-install-prompt/ and https://medium.com/swlh/a-simple-react-hook-to-prompt-ios-users-to-install-your-wonderful-pwa-4cc06e7f31fa.
Written by Phil Hardwick