「なんで昼休みのあと戻ると白画面になるの?」
Cursor で綺麗な Expo app を作った。Claude Code が auth flow、dashboard、settings screen を生成してくれた。出荷した。ユーザーも入った。すると DM が飛んでくる。
- 「昼休みのあと開くと白画面になるのはなんで?」
- 「なんで毎朝ログインし直しになるの?」
- 「Instagram から戻ったらデータが消えたんだけど?」
壊れた app を作ったわけではありません。ネイティブ app の箱の中に web app の発想を持ち込んだだけです。 Browser には app lifecycle がない。mobile app にはある。ユーザーがホームボタンを押しても app は死なず、眠る。6 時間後にアイコンをタップすると、また起きる。そして 6 時間前に正しかった前提は、もう全部あやしくなっています。
resume 時の問題
ユーザーが数時間ぶりに戻ってきたとき、判断しないといけないことがあります。session はまだ有効か。data は十分に新しいか。多くの Vibe-coded app はこれを screen ごとに場当たりで処理します。ある screen は再 fetch する。別の screen は stale data を出し続ける。3 つ目の screen は session が残っている前提で落ちる。
解決策は、最後に active だった時刻からの経過時間で resumeStatus を導出し、app 全体がそれに反応するようにすることです。
// src/context/app-lifecycle-provider.tsx
import { AppState } from 'react-native'
export type AppResumeStatus =
'fresh' | 'resumed' | 'stale' | 'expired'
export function getResumeStatus(
lastActiveAt: number,
now: number,
staleMs: number = 5 * 60 * 1000, // 5 minutes
expireMs: number = 24 * 60 * 60 * 1000 // 24 hours
): AppResumeStatus {
const idleMs = now - lastActiveAt
if (idleMs >= expireMs) return 'expired'
if (idleMs >= staleMs) return 'stale'
return lastActiveAt === 0 ? 'fresh' : 'resumed'
}
4 つの state があり、それぞれ意味がはっきりしています。
- fresh: 初回起動。前回の session がない。
- resumed: 5 分以内に復帰。data はおそらく問題ない。
- stale: 5 分以上離れていた。重要な data を再 fetch するタイミング。
- expired: 24 時間以上離れていた。fresh launch に近い扱いをする。
Lifecycle Provider
AppState への subscribe は 1 回だけにする。そして導出した status をどこからでも読めるようにする。
export function AppLifecycleProvider({
children,
}: PropsWithChildren) {
const { storage } = useServices()
const [state, setState] = useState({
appState: AppState.currentState,
lastActiveAt: 0,
resumeStatus: 'fresh' as AppResumeStatus,
ready: false,
})
useEffect(() => {
const sub = AppState.addEventListener(
'change',
(nextAppState) => {
if (nextAppState === 'active') {
const resumeStatus = getResumeStatus(
state.lastActiveAt,
Date.now()
)
setState((s) => ({
...s,
appState: nextAppState,
resumeStatus,
}))
} else if (nextAppState === 'background') {
storage.fastStorage.set('lastActiveAt', Date.now())
setState((s) => ({
...s,
appState: nextAppState,
lastActiveAt: Date.now(),
}))
}
}
)
storage.fastStorage
.get<number>('lastActiveAt')
.then((ts) => {
setState((s) => ({
...s,
lastActiveAt: ts ?? 0,
resumeStatus: getResumeStatus(ts ?? 0, Date.now()),
ready: true,
}))
})
return sub.remove
}, [storage])
return (
<LifecycleContext.Provider value={state}>
{children}
</LifecycleContext.Provider>
)
}
ポイントはここです。auth、updates、data refresh のように resume を気にする provider は、全部この context の resumeStatus を読む。screen ごとに AppState.addEventListener を書かない。feature ごとに background 時間を推測しない。
route guard は両方を待つ
route guard は auth の復元 と lifecycle の ready の両方が揃うまで描画を待つべきです。
// app/_layout.tsx
function RootNavigator() {
const { session, loading } = useAuth()
const { ready: lifecycleReady } = useAppLifecycle()
if (loading || !lifecycleReady) return null
return (
<Stack>
<Stack.Protected guard={!session}>
<Stack.Screen name='login' />
</Stack.Protected>
<Stack.Protected guard={!!session}>
<Stack.Screen name='(app)' />
</Stack.Protected>
</Stack>
)
}
web 開発だけをやってきた人がこれを考えなくて済むのは、browser には app lifecycle がないからです。mobile 開発ではそうはいきません。Autotomy Expo Starter Pack には lifecycle 管理が最初から入っているので、Vibe-coded app が「ユーザーが戻ってきた瞬間」に壊れるのを防げます。