Wick Technology Blog

Prompt to install a PWA on iOS and Android with React Hooks

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.

Has the user been prompted recently?

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.

iOS

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.

All other platforms

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;

How to use the hooks

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.

iOS Install 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.

Install modal

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 &quot;Add to Home Screen&quot;
              </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.

Conclusion

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.

Attribution and Further Reading

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.


Phil Hardwick

Written by Phil Hardwick