再インストールしたユーザーが「初回」に戻らない
ある日、個人開発のアプリで「機種変更したらオンボーディングが出なかった」という問い合わせを受けました。普通は逆を心配します。再インストールでオンボーディングがまた出てしまうほうがよくある不満だからです。調べてみると、原因はストレージごとに「アンインストールで消えるか残るか」が違うことにありました。私が初回判定に使っていたフラグは、片方の端末では消え、もう片方では消えずに残っていたのです。
このとき腑に落ちたのは、再インストール周りの不具合はバグというより、ストレージの永続性の非対称を設計者が把握していないことから生まれるという点でした。その非対称を正面から扱い、初回判定・オンボーディング・無料トライアルを再インストールで崩さないための設計を、実装込みで共有します。
アンインストールで「消えるもの」と「残るもの」
まず事実を正確に押さえます。Expo/React Native アプリがアプリを削除したとき、何が消えて何が残るかはストレージと OS で異なります。とくに iOS の Keychain は、アプリを削除しても項目が残ることがある点が直感に反します。
| 保存先 | iOS でアンインストール後 | Android でアンインストール後 |
| AsyncStorage / MMKV / ファイル | 消える | 消える |
| SecureStore (Keychain) | 残ることがある | 消える |
| SharedPreferences | — | 自動バックアップで復元され得る |
| iCloud Keychain 同期項目 | 端末をまたいで残る | — |
| サーバ上のアカウント | 残る(端末非依存) | 残る(端末非依存) |
この表が、再インストール周りの挙動のほぼすべてを説明します。AsyncStorage に初回フラグを置けば、再インストールでフラグが消えるのでオンボーディングがまた出ます。一方 SecureStore に「トライアル消化済み」を置けば、iOS では削除後も残るので再インストールしてもトライアルが戻りません(戻したい場合も戻したくない場合もあり、ここが設計判断です)。Android では SecureStore も消えますが、SharedPreferences は自動バックアップ設定次第で復元され得ます。
つまり「消えるか残るか」はストレージ選択で決まる仕様であって、偶然ではありません。設計の出発点は、各状態について『再インストールで消えてよいか/残ってほしいか』を先に決め、それに合うストレージに置くことです。
まず「どう振る舞ってほしいか」を決める
状態ごとに望ましい挙動は違います。私はこのアプリ群を運用する中で、状態を足すたびに次の3問を自分に投げます。
- この状態は再インストールでリセットされるべきか(例: ローカルの下書き、UI設定)
- この状態は再インストールでも残るべきか(例: 不正なトライアル再取得を防ぐ識別子)
- この状態は端末ではなくサーバが持つべきか(例: 購入・サブスク・アカウント)
下表は私の既定の割り当てです。
| 状態 | 望ましい挙動 | 置き場所 |
| オンボーディング完了 | 再インストールで再表示してよい | AsyncStorage |
| UI 設定・テーマ | 消えてよい | AsyncStorage |
| 無料トライアル消化 | 残ってほしい(再取得防止) | サーバ(補助に SecureStore) |
| 購入・サブスク権利 | 端末非依存で残る | サーバ / ストア(StoreKit/Play) |
| インストール識別子 | 同一端末では残ってほしい | SecureStore |
要点は、お金と不正に関わる状態を端末ストレージだけで判定しないことです。AsyncStorage は再インストールで消えるので、ここに「トライアル済み」を置くと、削除→再インストールでトライアルが無限に取り直せます。逆に SecureStore だけに頼ると、iOS と Android で挙動が割れます。お金が絡む判定は最終的にサーバが持つべきで、端末ストレージは速度のためのキャッシュに留めます。
初回・更新・再インストールを取り違えない
「初回起動」と一言で言っても、実際には3つの異なる状況があります。これらを1つのフラグで扱うと必ずどこかが破綻します。
- 新規インストール(初回): その端末で初めて起動した
- 更新後の初回起動: 同じインストールでアプリのバージョンが上がった
- 再インストール: 一度削除して入れ直した
この3つを見分ける土台が、SecureStore に置く永続インストール識別子です。SecureStore は iOS で削除後も残ることがあるため、これを「同一端末での再インストール検出」に逆手に取ります。
// install-identity.ts — インストール状況を3状態で判定する
import * as SecureStore from "expo-secure-store";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Application from "expo-application";
import { randomUUID } from "expo-crypto";
type InstallState = "fresh" | "update" | "reinstall";
export async function resolveInstallState(): Promise<{
state: InstallState;
installId: string;
}> {
const currentVersion = Application.nativeApplicationVersion ?? "0";
// SecureStore は iOS で削除後も残ることがある(端末に紐づく永続層)
let installId = await SecureStore.getItemAsync("install_id");
// AsyncStorage は削除で必ず消える(インストールに紐づく揮発層)
const lastVersion = await AsyncStorage.getItem("last_version");
let state: InstallState;
if (lastVersion === null) {
// AsyncStorage が空 = このインストールでは初起動
// SecureStore に id が残っていれば「同一端末への再インストール」
state = installId ? "reinstall" : "fresh";
} else if (lastVersion !== currentVersion) {
state = "update";
} else {
state = "fresh"; // 厳密には通常起動。呼び出し側で扱いを決める
}
if (!installId) {
installId = randomUUID();
await SecureStore.setItemAsync("install_id", installId);
}
await AsyncStorage.setItem("last_version", currentVersion);
return { state, installId };
}
ここでの非対称の使い方が肝です。AsyncStorage(消える層)が空かどうかで「このインストールでの初起動か」を見て、SecureStore(残る層)に id があるかどうかで「同じ端末への再インストールか」を見分けます。新規なら両方が空、再インストールなら AsyncStorage は空・SecureStore は埋まっている、という差で判定します。
expo-application の nativeApplicationVersion を AsyncStorage に保存しておけば、更新の検出も同じ関数で済みます。これで「初回だけ見せたいもの」と「更新ごとに見せたいもの」を別軸で扱えます。
オンボーディングは「インストール軸」、What's New は「バージョン軸」
混同されがちですが、オンボーディングと「更新後の新機能紹介(What's New)」は別の軸に属します。オンボーディングはインストールに紐づき、What's New はバージョンに紐づきます。これを分けて持つと、両方が正しく動きます。
// gates.ts — 2つの軸を別フラグで管理する
export async function shouldShowOnboarding(): Promise<boolean> {
// インストール軸: 再インストールで再表示してよいので AsyncStorage で十分
const done = await AsyncStorage.getItem("onboarding_done");
return done !== "1";
}
export async function shouldShowWhatsNew(currentVersion: string): Promise<boolean> {
// バージョン軸: 「どのバージョンまで見たか」を保持
const seen = await AsyncStorage.getItem("whatsnew_seen_version");
return seen !== currentVersion;
}
オンボーディングを AsyncStorage に置くのは意図的です。再インストールしたユーザーには、もう一度導線を見せたほうが親切なことが多いからです(最初に挫折して消した人ほど、入れ直しは再挑戦の合図です)。一方 What's New を「見たバージョン」で持てば、更新のたびに一度だけ出て、通常起動では出ません。冒頭の問い合わせ(再インストールでオンボーディングが出ない)は、オンボーディング完了フラグを SecureStore に置いてしまっていたために起きた典型でした。インストール軸の状態を残る層に置くと、再インストールで「初回」に戻らなくなります。
トライアルと権利はサーバを権威にする
無料トライアルの消化や購入の権利は、端末ストレージで判定してはいけません。AsyncStorage は削除で消えるのでトライアルが取り直せてしまい、SecureStore は iOS と Android で挙動が割れます。ここはサーバを単一の権威にします。
// trial.ts — 端末識別子をキーにサーバへ照会する(端末ストレージは判定に使わない)
export async function checkTrialEligibility(installId: string): Promise<{
eligible: boolean;
reason: "new" | "consumed" | "active";
}> {
const res = await fetch("https://api.example.com/trial/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ installId }),
});
if (!res.ok) {
// 通信不可時は「付与しない」側に倒す(不正取得より UX 劣化を選ぶ)
return { eligible: false, reason: "consumed" };
}
return res.json();
}
通信できないときに eligible: false(付与しない側)へ倒すのが設計判断です。逆に倒すと、機内モードでトライアルを無限取得される穴になります。購入・サブスクについては、自前サーバよりストアの権利(StoreKit/Play、もしくは RevenueCat のような権利管理)を一次情報にし、端末はその結果をキャッシュするだけにします。端末キャッシュはあくまで起動を速くするためのもので、真偽の最終判断はサーバ側に置きます。
Android の自動バックアップという落とし穴
iOS の Keychain 残存が有名ですが、Android にも対称的な罠があります。Android Auto Backup が有効だと、SharedPreferences の中身がクラウド経由で復元され、再インストールでも一部の状態が「残って」しまうことがあります。iOS では消える前提で書いた状態が、Android では復元されて挙動が割れる、という事態が起こります。
復元してほしくない機微な状態は、android/app/src/main/res/xml/ のバックアップ規則で明示的に除外します。
<!-- backup_rules.xml — トライアルや識別子をクラウド復元から除外 -->
<full-backup-content>
<exclude domain="sharedpref" path="trial_state.xml" />
<exclude domain="sharedpref" path="install_identity.xml" />
</full-backup-content>
どのストレージが「どの OS で・どの操作で」生き残るかは、必ず実機で確かめます。シミュレータ/エミュレータと実機で挙動が違う領域なので、私は新しい状態を足したら、実機で「削除→再インストール」を一度通して、初回判定とトライアルが想定どおりかを目で確認するようにしています。
移行と検証のチェックリスト
最後に、既存アプリにこの設計を入れるときの手順をまとめます。
- 既存の初回フラグがどのストレージにあるかを棚卸しする(AsyncStorage か SecureStore か)
- 各状態に「再インストールで消えるべきか/残るべきか」を割り当て直す(上の表を基準に)
- オンボーディング完了を AsyncStorage(インストール軸)へ、What's New をバージョン軸へ分離する
- トライアル・権利の判定を端末からサーバへ移し、端末はキャッシュに降格する
- Android のバックアップ規則で、復元してほしくない機微状態を除外する
- 実機で「新規/更新/削除→再インストール」の3経路を通し、各フラグの挙動を確認する
- 通信不可時のトライアル判定が「付与しない」側に倒れることを機内モードで確認する
この棚卸しで効いてくるのは、ストレージ選択が仕様そのものだという視点です。どこに置くかを決めた時点で、再インストール時の振る舞いはもう決まっています。
おわりに
再インストール周りの不具合は、個別のバグに見えて、根は「消える層と残る層の非対称を把握していない」ことにあります。状態ごとに望ましい挙動を先に決め、それに合う層へ置き、お金と不正はサーバを権威にする。インストール軸とバージョン軸を分ける。この整理だけで、再インストールで初回に戻らない・トライアルが取り直せる・更新案内が出ない、といった症状はまとめて消えます。
まずは自分のアプリの初回フラグが AsyncStorage と SecureStore のどちらにあるかを確認するところから始めてみてください。たった1つのフラグの置き場所が、再インストール時の体験を静かに左右しています。私自身、この棚卸しで再インストール周りの不安がずいぶん減りました。同じように長く使われるアプリを育てている方の参考になれば幸いです。