“Почему после обеда приложение открывается с пустым экраном?”
Вы собрали красивое Expo-приложение в Cursor. Claude Code сгенерировал auth flow, dashboard и экран настроек. Вы зарелизили его. Пользователи начали скачивать. А потом посыпались сообщения:
- “Почему после обеда приложение открывается с пустым экраном?”
- “Почему каждое утро мне приходится логиниться заново?”
- “Почему данные исчезли, когда я вернулся из Instagram в приложение?”
Вы не сделали сломанное приложение. Вы построили веб-мышление внутри контейнера нативного приложения. У браузеров нет app lifecycle. У мобильных приложений он есть. Когда пользователь нажимает кнопку Home, приложение не умирает, а засыпает. Когда он через шесть часов снова нажимает на иконку, приложение просыпается. И всё, что было правдой шесть часов назад, теперь уже ложь.
Проблема возврата в приложение
Когда пользователь возвращается через несколько часов, вам нужно принять решения. Сессия ещё валидна? Данные достаточно свежие? Большинство vibe-coded приложений решают это на ходу, отдельно на каждом экране, и в итоге получают разнобой. Один экран делает re-fetch. Другой показывает устаревшие данные. Третий падает, потому что предполагает, что сессия всё ещё на месте.
Исправление такое: вычисляйте resumeStatus по времени, прошедшему с последней активности пользователя, и дайте всему приложению реагировать на него:
// 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'
}
Четыре состояния, и у каждого понятный смысл:
- fresh: Первый запуск. Предыдущей сессии нет.
- resumed: Возврат меньше чем через 5 минут. Данные, скорее всего, ещё актуальны.
- stale: Приложение было закрыто 5+ минут. Пора заново запросить ключевые данные.
- expired: Приложение было закрыто 24+ часа. Обрабатывайте это как новый запуск.
Lifecycle provider
Подпишитесь на AppState один раз. Дальше просто отдавайте вычисленный статус во всё приложение:
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, обновления, refresh данных - любой provider, которому важно возвращение пользователя, читает resumeStatus из одного общего контекста. Ни один экран не пишет свой собственный AppState.addEventListener. Ни одна фича не гадает, сколько времени приложение провело в фоне.
Route guards ждут и auth, и lifecycle
Ваши route guards должны ждать и восстановления auth, и готовности lifecycle, прежде чем что-либо рендерить:
// 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>
)
}
Веб-разработчики об этом почти не думают, потому что у браузеров нет app lifecycle. У мобильных разработчиков такой роскоши нет. В Autotomy Expo Starter Pack управление lifecycle уже встроено, чтобы ваше vibe-coded приложение не ломалось, когда пользователь возвращается спустя время.