はじめに
PWA(Progressive Web App)のインストール率を上げたいなら、ブラウザのアドレスバーに出る小さなアイコンだけに頼るのは心もとない。
自前の「インストール」ボタンを表示して、ユーザーに能動的にインストールを促す。この仕組みの中核にあるのが beforeinstallprompt イベントだ。ブラウザが「このサイトはインストール可能だ」と判断したときに発火するイベントで、このイベントオブジェクトを保持しておけば、任意のタイミングでインストールダイアログを表示できる。
シンプルに聞こえる。実際、HTML と vanilla JavaScript で書くなら難しくない。
ところが React(Next.js)で実装しようとすると、予想外の問題に直面した。イベントが発火するタイミングが、React のライフサイクルと噛み合わないのだ。
Wakulier の PWA 対応で実際にこの問題にハマり、Claude Code と一緒に解決した経緯を共有する。
問題の発見
Wakulier は案件管理ツールとして PWA 対応を進めていた。ホーム画面からすぐに起動できる体験は、日常的に使うツールでは大きな価値がある。
2026年1月7日の開発日記にはこう書いている。
Android PWAインストールボタンが機能しない問題を修正
beforeinstallpromptの早期キャプチャパターンを実装
「機能しない」と一行で書いているが、ここにたどり着くまでに試行錯誤があった。
最初の実装はこうだった。React のカスタムフックで beforeinstallprompt イベントをリッスンし、useState でイベントオブジェクトを保存する。
function usePwaInstall() {
const [promptEvent, setPromptEvent] = useState(null);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setPromptEvent(e);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
return promptEvent;
}
一見、正しそうに見える。だが、Android の Chrome で実際にテストすると、インストールボタンが表示されない。promptEvent が常に null のままだった。
なぜ useState ではダメなのか
原因を調べてみると、タイミングの問題だった。
beforeinstallprompt イベントは、ブラウザが Service Worker の登録やマニフェストの検証を終え、「インストール可能」と判断した瞬間に発火する。このタイミングはブラウザの内部処理に依存しており、開発者がコントロールできない。
問題は、このイベントが React コンポーネントの useEffect が実行される前に発火することがある点だ。
React の useEffect はコンポーネントのマウント後に実行される。しかし、ブラウザの beforeinstallprompt は DOM の構築とは無関係に発火する。ページの読み込みが速い場合や、Service Worker が既にキャッシュされている場合、React がマウントを完了する前にイベントが飛んでくることがある。
イベントリスナーを登録する前にイベントが発火してしまえば、当然キャッチできない。useState に何もセットされず、インストールボタンは表示されない。
開発日記にはこの問題の本質をこう書いた。
イベントが発火するタイミングが予測不能なため、グローバル変数に保存してフック内で再利用
「予測不能」がポイントだ。速いネットワーク環境ではイベントが先に来るし、遅い環境ではフックのセットアップが先に完了する。どちらが先になるか分からない以上、「どちらが先でも拾える」仕組みが必要だった。
解決策の検討
この問題をどう解決するか。Claude Code と3つのアプローチを検討した。
Zeronova
beforeinstallpromptが React のマウント前に発火してしまう。イベントを確実にキャプチャする方法を考えたい。グローバル変数に保存するのが手っ取り早いと思うが、他にいいアプローチはある?Claude Code3つのアプローチが考えられます。1つ目はグローバル変数パターンで、<script>タグやlayout.tsxの<Script>でイベントをwindowに保存する方法です。最もシンプルで、React のライフサイクルに依存しません。2つ目はカスタムイベントパターンで、beforeinstallpromptを受けたら独自のカスタムイベントを発行し、React 側でそれをリッスンする方法です。ただし結局グローバルなリスナーが必要なので、複雑さが増すだけかもしれません。3つ目は Context API パターンで、アプリのルートに Provider を置いてイベントを管理する方法です。ただし Provider 自体もuseEffectを使うので、同じタイミング問題が発生します。Zeronova
Context API は同じ問題を抱えるなら意味がない。カスタムイベントも結局グローバルスコープを使うなら、直接グローバル変数に保存する方がシンプルだ。グローバル変数パターンでいこう。
この判断のポイントは「シンプルさ」だった。グローバル変数を使うのは React のベストプラクティスからは外れる。しかし beforeinstallprompt はそもそもブラウザのグローバルイベントであり、React の外側で発生するものだ。React の流儀に無理に合わせるより、ブラウザの流儀に合わせた方が自然だと判断した。
実装
Claude Code に依頼して実装したパターンは、2段構えになっている。
第1段階:グローバルでのキャプチャ
React が動き始める前に、ブラウザイベントをグローバル変数に保存する。
// layout.tsx(Script コンポーネントで最初期に実行)
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
window.__pwaInstallPromptEvent = e;
});
このコードは React のライフサイクルとは無関係に動く。ページの読み込み直後から beforeinstallprompt を待ち構え、発火した瞬間にグローバル変数へ保存する。
e.preventDefault() を呼ぶのは、ブラウザのデフォルトのインストールバナーを抑制するためだ。自前のUIでインストールを促す場合、ブラウザ標準のバナーが出ると二重表示になってしまう。
第2段階:React フックでの参照
コンポーネント側では、グローバル変数を参照するフックを用意する。
function usePwaInstall() {
const [canInstall, setCanInstall] = useState(false);
useEffect(() => {
// マウント時にグローバル変数をチェック
if (window.__pwaInstallPromptEvent) {
setCanInstall(true);
}
// マウント後に発火した場合にも対応
const handler = (e: Event) => {
e.preventDefault();
window.__pwaInstallPromptEvent = e;
setCanInstall(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
return canInstall;
}
ポイントは useEffect の中で2つのケースに対応している点だ。
1つ目は「イベントが既に発火済み」のケース。window.__pwaInstallPromptEvent が存在するなら、イベントは React マウント前に来ている。その場合はすぐに canInstall を true にする。
2つ目は「イベントがまだ来ていない」のケース。通常通りイベントリスナーを登録し、後から発火するのを待つ。
この2段構えにより、イベントがいつ発火しても確実にキャプチャできる。
インストールボタンのUI
canInstall が true のとき、インストールを促すボタンを表示する。ボタンをタップすると、保存しておいたイベントオブジェクトの prompt() メソッドを呼ぶ。
const handleInstall = async () => {
const event = window.__pwaInstallPromptEvent;
if (!event) return;
await event.prompt();
const result = await event.userChoice;
if (result.outcome === 'accepted') {
window.__pwaInstallPromptEvent = null;
}
};
event.prompt() がブラウザのインストールダイアログを表示する。ユーザーが「インストール」を選択したら、グローバル変数をクリアする。beforeinstallprompt は一度しか使えないため、使い終わったら破棄するのが正しい。
セキュリティ監査との関連
この修正を行ったのと同じ日、Wakulier の初回セキュリティ監査も実施した。開発日記にはこう記録されている。
初回セキュリティ監査を実施 22件の問題を検出、全て修正
PWA のインストールボタン修正とセキュリティ監査を同日に行ったのは偶然ではない。本番公開前の最終チェックとして、セキュリティと UX の両面を一気に潰す日だった。
セキュリティ監査では RLS(Row Level Security)の漏れや入力検証の不足、エラーメッセージからの情報漏洩など22件の問題を Claude Code と一緒に検出・修正した。未使用の /api/settings エンドポイントも削除した。使わないエンドポイントは攻撃対象面を無駄に広げるだけだ。
開発日記の学びにもこう書いている。
セキュリティ監査は「チェックリスト化」すると漏れにくい 未使用コードは積極的に削除(技術的負債を減らす)
「インストールボタンが動かない」という UX の問題も、「不要なエンドポイントが残っている」というセキュリティの問題も、根は同じだ。本番環境で実際にユーザーが触る前に、徹底的に潰しておく。この姿勢が大切だと改めて感じた日だった。
学び
ブラウザイベントと React ライフサイクルの不整合
今回の問題は、ブラウザのイベントモデルと React のライフサイクルモデルが根本的に異なるものだという事実を突きつけてくる。
ブラウザのイベントはグローバルに発生する。DOMContentLoaded、load、beforeinstallprompt のようなイベントは、React がどういう状態であろうと関係なく発火する。一方、React の useEffect はコンポーネントのマウント後に実行される。この2つのタイミングは保証されていない。
beforeinstallprompt に限らず、同様のパターンは他にもある。たとえば online/offline イベントや、visibilitychange イベントも、React のライフサイクルとは独立して発火する。これらのイベントを扱う際は、「React の外側でキャプチャし、React の内側で参照する」という2段構えのパターンが有効だ。
グローバル変数は「悪」ではない
React 開発では「グローバルな状態は避けるべき」とよく言われる。Context API や状態管理ライブラリを使うのが推奨される。
しかし今回のケースでは、グローバル変数が最もシンプルで確実な解決策だった。beforeinstallprompt はブラウザのグローバルスコープで発生するイベントだ。それをグローバル変数に保存するのは、むしろ自然な設計と言える。
「ベストプラクティス」を教条的に適用するのではなく、問題の性質に合わせて手段を選ぶ。PM として技術選択を判断する際に、改めて意識したいポイントだ。
PWA のインストール促進は「受動的」ではダメ
ブラウザが自動で表示するインストールバナーだけに頼ると、多くのユーザーは気づかない。あるいは、タイミングが悪くて閉じてしまう。
自前のインストールボタンを適切なタイミング(たとえばアプリを数回使った後)で表示する方が、インストール率は上がる。そのためには beforeinstallprompt を確実にキャプチャしておく必要がある。
今回のパターンは、その土台となる実装だ。
まとめ
| 課題 | 解決策 |
|---|---|
beforeinstallprompt が React マウント前に発火する | グローバル変数でイベントを早期キャプチャ |
useState ではタイミングに依存して取りこぼす | 2段構え(グローバル保存 + フック内チェック) |
| ブラウザ標準バナーとの二重表示 | e.preventDefault() で抑制 |
| イベントの使い捨て管理 | インストール後にグローバル変数をクリア |
beforeinstallprompt のキャプチャは、PWA のインストール促進を実装するうえで避けて通れない。React を使っている場合は、ブラウザイベントとライフサイクルの不整合を意識した設計が必要になる。
「React の中だけで完結させたい」という気持ちは分かる。だが、ブラウザのイベントモデルは React よりも先に存在するものだ。React の流儀に固執するより、ブラウザの流儀に合わせた方がうまくいくケースがある。今回の beforeinstallprompt は、まさにそういう事例だった。
関連記事
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。