“Почему после обеда приложение открывается с пустым экраном?”

Вы собрали красивое 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 приложение не ломалось, когда пользователь возвращается спустя время.