Skip to content

Instantly share code, notes, and snippets.

@rikukissa
Last active May 6, 2024 11:52
Show Gist options
  • Star 57 You must be signed in to star a gist
  • Fork 8 You must be signed in to fork a gist
  • Save rikukissa/cb291a4a82caa670d2e0547c520eae53 to your computer and use it in GitHub Desktop.
Save rikukissa/cb291a4a82caa670d2e0547c520eae53 to your computer and use it in GitHub Desktop.
React Hook prompting the user to "Add to homescreen" ๐Ÿ  #PWA #React
title slug createdAt language preview
React Hook prompting the user to "Add to homescreen"
react-hook-prompting-the-user-to-add
2018-11-29T20:35:02Z
en
Simple React Hook for showing the user a custom "Add to homescreen" prompt.

React Hook for showing custom "Add to homescreen" prompt

Demo:
Twitter

Simple React Hook for showing the user a custom "Add to homescreen" prompt.

const [prompt, promptToInstall] = useAddToHomescreenPrompt();

Listens for beforeinstallprompt event, which notifies you when the browser would have shown the default dialog, intercepts it and lets you take over and show the prompt when ever you please.

Browser support and requirements

Add to Home Screen

Browser support is still quite lacking. At the time of writing, only Chrome (Desktop + Android) is supported.

Implementation

import * as React from "react";

interface IBeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: Promise<{
    outcome: "accepted" | "dismissed";
    platform: string;
  }>;
  prompt(): Promise<void>;
}

export function useAddToHomescreenPrompt(): [
  IBeforeInstallPromptEvent | null,
  () => void
] {
  const [prompt, setState] = React.useState<IBeforeInstallPromptEvent | null>(
    null
  );

  const promptToInstall = () => {
    if (prompt) {
      return prompt.prompt();
    }
    return Promise.reject(
      new Error(
        'Tried installing before browser sent "beforeinstallprompt" event'
      )
    );
  };

  React.useEffect(() => {
    const ready = (e: IBeforeInstallPromptEvent) => {
      e.preventDefault();
      setState(e);
    };

    window.addEventListener("beforeinstallprompt", ready as any);

    return () => {
      window.removeEventListener("beforeinstallprompt", ready as any);
    };
  }, []);

  return [prompt, promptToInstall];
}

Example component

import * as React from "react";
import { useAddToHomescreenPrompt } from "./useAddToHomescreenPrompt";

export function ExampleComponent() {
  const [prompt, promptToInstall] = useAddToHomescreenPrompt();
  const [isVisible, setVisibleState] = React.useState(false);

  const hide = () => setVisibleState(false);

  React.useEffect(
    () => {
      if (prompt) {
        setVisibleState(true);
      }
    },
    [prompt]
  );

  if (!isVisible) {
    return <div />;
  }

  return (
    <div onClick={hide}>
      <button onClick={hide}>Close</button>
      Hello! Wanna add to homescreen?
      <button onClick={promptToInstall}>Add to homescreen</button>
    </div>
  );
}
@teebot
Copy link

teebot commented Jun 4, 2019

If I'm not mistaken the event is triggered once onload so you can only use that effect in your root app component if you have different routes. (which is no big deal since we can pass prompt and promptinstall as props to child components)

@rikukissa
Copy link
Author

Good point! I guess the default use case would be to use this only once per application, but it would still be possible to refactor this to work with many calls in the component tree.

@Krak86
Copy link

Krak86 commented Nov 4, 2019

Hey man, it works perfect, thanks a lot!

@rikukissa
Copy link
Author

Glad you liked it :)

@benstov
Copy link

benstov commented Dec 16, 2019

Good point! I guess the default use case would be to use this only once per application, but it would still be possible to refactor this to work with many calls in the component tree.

please do so ๐Ÿ‘

@loxator
Copy link

loxator commented May 19, 2020

Couldn't get it to work :\

@rikukissa
Copy link
Author

Couldn't get it to work :\

Check that your app meets the requirements of the home screen prompt https://web.dev/install-criteria/

@loxator
Copy link

loxator commented May 19, 2020

Check that your app meets the requirements of the home screen prompt https://web.dev/install-criteria/

You were right, it was because I had wrong path for the icons. I thought the manifest was showing warnings but it was actually errors.

It works as expected! Thank you for your work @rikukissa

@rikukissa
Copy link
Author

Good to hear :)

@jasoft92
Copy link

Hey! Thanks for this example! Still no support on other browsers other than Chrome? Thanks

@loxator
Copy link

loxator commented May 24, 2020

@jasoft92 The support is from the browsers itself. You can check it here https://caniuse.com/#feat=web-app-manifest

@jasoft92
Copy link

@loxator thanks for the link - very useful! Yes that is what I was referring to, which other browsers support it and it's a pity to see so very little support from other major browsers.

@mwmcode
Copy link

mwmcode commented Aug 16, 2020

I ended up wrapping it in a context.

Provider/Consumer

// PromptToInstallProvider.tsx
import React, { createContext, useEffect, useContext, useCallback } from 'react';
import { Children, IBeforeInstallPromptEvent, PromptCtx } from './types';

const PromptToInstall = createContext<PromptCtx>({deferredEvt: null});

export function PromptToInstallProvider(props: Children) {
  const [deferredEvt, setDeferredEvt] = React.useState<IBeforeInstallPromptEvent | null>(
    null,
  );

  const hidePrompt = useCallback(() => {
    setDeferredEvt(null);
  }, []);

  useEffect(() => {
    const ready = (e: IBeforeInstallPromptEvent) => {
      e.preventDefault();
      setDeferredEvt(e);
    };

    window.addEventListener('beforeinstallprompt', ready as any);

    return () => {
      window.removeEventListener('beforeinstallprompt', ready as any);
    };
  }, []);

  return (
    <PromptToInstall.Provider value={{deferredEvt, hidePrompt}}>
      {props.children}
    </PromptToInstall.Provider>
  );
}

export function usePromptToInstall() {
  const ctx = useContext(PromptToInstall);
  if (!ctx) {
    throw new Error('Cannot use usePromptToInstall() outside <PromptToInstallProvider />');
  }
  return ctx;
}

Example

import React from 'react';
import { usePromptToInstall } from 'shared/providers/propmptToInstall';

export default function InstallPrompt() {
  const { deferredEvt, hidePrompt } = usePromptToInstall();

  if (!deferredEvt) {
    return null;
  }
  return (
      <span>
        <p>Install app to your device?</p>
        <div>
          <button onClick={deferredEvt.prompt}>Yes</button>
          <button onClick={hidePrompt}>No, hide message</button>
        </div>
      </span>
  );
}

Types used

// types.ts
export interface IBeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: Promise<{
    outcome: 'accepted' | 'dismissed';
    platform: string;
  }>;
  prompt(): Promise<void>;
}
export type PromptCtx = {
  deferredEvt: IBeforeInstallPromptEvent | null;
  hidePrompt?:() => void;
};
export type Children = {
  children: ReactElement | ReactElement[] | string | null;
};

@Shubham0850
Copy link

It is not working on my project...
It is giving an error says:--
Type annotations can only be used in TypeScript files.

@loxator
Copy link

loxator commented Sep 11, 2020

@Shubham0850 It's a Typescript file. You need to remove the types assigned to it to make it work with JS. Or you could copy the file and name it with a .ts extension

@OleksandrDanylchenko
Copy link

OleksandrDanylchenko commented Mar 21, 2021

I ended up wrapping it in a context.

Provider/Consumer

// PromptToInstallProvider.tsx
import React, { createContext, useEffect, useContext, useCallback } from 'react';
import { Children, IBeforeInstallPromptEvent, PromptCtx } from './types';

const PromptToInstall = createContext<PromptCtx>({deferredEvt: null});

export function PromptToInstallProvider(props: Children) {
  const [deferredEvt, setDeferredEvt] = React.useState<IBeforeInstallPromptEvent | null>(
    null,
  );

  const hidePrompt = useCallback(() => {
    setDeferredEvt(null);
  }, []);

  useEffect(() => {
    const ready = (e: IBeforeInstallPromptEvent) => {
      e.preventDefault();
      setDeferredEvt(e);
    };

    window.addEventListener('beforeinstallprompt', ready as any);

    return () => {
      window.removeEventListener('beforeinstallprompt', ready as any);
    };
  }, []);

  return (
    <PromptToInstall.Provider value={{deferredEvt, hidePrompt}}>
      {props.children}
    </PromptToInstall.Provider>
  );
}

export function usePromptToInstall() {
  const ctx = useContext(PromptToInstall);
  if (!ctx) {
    throw new Error('Cannot use usePromptToInstall() outside <PromptToInstallProvider />');
  }
  return ctx;
}

Example

import React from 'react';
import { usePromptToInstall } from 'shared/providers/propmptToInstall';

export default function InstallPrompt() {
  const { deferredEvt, hidePrompt } = usePromptToInstall();

  if (!deferredEvt) {
    return null;
  }
  return (
      <span>
        <p>Install app to your device?</p>
        <div>
          <button onClick={deferredEvt.prompt}>Yes</button>
          <button onClick={hidePrompt}>No, hide message</button>
        </div>
      </span>
  );
}

Types used

// types.ts
export interface IBeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: Promise<{
    outcome: 'accepted' | 'dismissed';
    platform: string;
  }>;
  prompt(): Promise<void>;
}
export type PromptCtx = {
  deferredEvt: IBeforeInstallPromptEvent | null;
  hidePrompt?:() => void;
};
export type Children = {
  children: ReactElement | ReactElement[] | string | null;
};

Always getting TypeError: Failed to execute 'prompt' on 'BeforeInstallPromptEvent': Illegal invocation, when I'm trying to use deferredEvt.prompt, event if the functions is defined. What can be the problem?

@mwmcode
Copy link

mwmcode commented Mar 21, 2021

Weird! This error (Illegal invocation) seems to refer to the context in which the function is being executed (gathered from this SO answer).

I just tried it again on Chrome browser (Mac OS) and it worked
The only difference I have is

const onInstall = () => {
    if (deferredEvt?.prompt) {
      deferredEvt.prompt();
      invalidateEvt(); 
    } else if (BROWSER.isSafariMobile) {
      setShowIOSBox(true);
    }
};

If you have a minimal reproduceable codebase, I can look into it further

@francescogior
Copy link

Hi, I added a listener for the appinstalled event and made the hook return a third boolean flag

import * as React from "react";

interface IBeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: Promise<{
    outcome: "accepted" | "dismissed";
    platform: string;
  }>;
  prompt(): Promise<void>;
}

export function useAddToHomescreenPrompt(): [
  IBeforeInstallPromptEvent | null,
  () => void,
  boolean
] {
  const [
    promptable,
    setPromptable,
  ] = React.useState<IBeforeInstallPromptEvent | null>(null);

  const [isInstalled, setIsInstalled] = React.useState(false);

  const promptToInstall = () => {
    if (promptable) {
      return promptable.prompt();
    }
    return Promise.reject(
      new Error(
        'Tried installing before browser sent "beforeinstallprompt" event'
      )
    );
  };

  React.useEffect(() => {
    const ready = (e: IBeforeInstallPromptEvent) => {
      e.preventDefault();
      setPromptable(e);
    };

    window.addEventListener("beforeinstallprompt", ready as any);

    return () => {
      window.removeEventListener("beforeinstallprompt", ready as any);
    };
  }, []);

  React.useEffect(() => {
    const onInstall = () => {
      setIsInstalled(true);
    };

    window.addEventListener("appinstalled", onInstall as any);

    return () => {
      window.removeEventListener("appinstalled", onInstall as any);
    };
  }, []);

  return [promptable, promptToInstall, isInstalled];
}
import * as React from "react";
import { useAddToHomescreenPrompt } from "./useAddToHomescreenPrompt";
import styled from "@emotion/styled";

const ButtonElement = styled.button({
  // style
});

export function AddToHomescreenButton() {
  const [promptable, promptToInstall, isInstalled] = useAddToHomescreenPrompt();

  return promptable && !isInstalled ? (
    <ButtonElement onClick={promptToInstall}>INSTALL APP</ButtonElement>
  ) : null;
}

@ankitchauhan-aka
Copy link

ankitchauhan-aka commented Nov 11, 2021

If I'm not mistaken the event is triggered once onload so you can only use that effect in your root app component if you have different routes. (which is no big deal since we can pass prompt and promptinstall as props to child components)

Using in footer component which is only available at some pages and prompt only happens if I reload page (sometimes fails even after page reload). Any more hints?

@tasandberg
Copy link

Thanks for this gist! For anyone that wants to avoid the any type:

React.useEffect(() => {
    const ready = (e: IBeforeInstallPromptEvent) => {
      e.preventDefault()
      setState(e)
    }

    window.addEventListener(
      "beforeinstallprompt",
      ready as EventListenerOrEventListenerObject
    )

    return () => {
      window.removeEventListener(
        "beforeinstallprompt",
        ready as EventListenerOrEventListenerObject
      )
    }
 }, [])

@Anhht1808
Copy link

On the computer everything is fine, but on both Android and iOS phones it doesn't work

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment