“Por que meu app mostra uma tela em branco depois do almoço?”

Você construiu um app Expo bonito no Cursor. O Claude Code gerou o fluxo de auth, o dashboard, a tela de settings. Você publicou. Os usuários baixaram. Aí começaram as DMs:

  • “Por que o app mostra uma tela em branco quando eu abro depois do almoço?”
  • “Por que eu preciso fazer login de novo toda manhã?”
  • “Por que meus dados sumiram quando voltei do Instagram?”

Você não construiu um app quebrado. Você construiu uma mentalidade de web app dentro de um container de app nativo. Browsers não têm ciclo de vida de app. Apps mobile têm. Quando o usuário aperta o botão Home, seu app não morre, ele dorme. Quando ele toca no seu ícone seis horas depois, o app acorda. E tudo o que era verdade seis horas atrás agora é mentira.

O Problema do Retorno

Quando um usuário volta depois de horas fora, você precisa tomar decisões. A sessão ainda é válida? Os dados ainda estão frescos o suficiente? A maioria dos apps feitos com vibe coding responde isso de forma ad hoc, tela por tela, com resultados inconsistentes. Uma tela faz re-fetch. Outra mostra dados stale. Uma terceira quebra porque assume que a sessão ainda existe.

A correção: derive um resumeStatus com base no tempo desde a última vez em que o usuário esteve ativo e deixe o app inteiro reagir a isso:

// 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'
}

Quatro estados, cada um com um significado claro:

  • fresh: Primeiro lançamento. Sem sessão anterior.
  • resumed: De volta em menos de 5 minutos. Os dados provavelmente ainda estão bons.
  • stale: Fora por mais de 5 minutos. Hora de buscar novamente os dados principais.
  • expired: Fora por mais de 24 horas. Trate como um lançamento novo.

O Lifecycle Provider

Assine AppState uma vez. Exponha o status derivado em toda a app:

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>
  )
}

O insight principal: auth, updates, refresh de dados, qualquer provider que se importa com retomada lê resumeStatus deste contexto único. Nenhuma tela escreve o próprio AppState.addEventListener. Nenhuma feature tenta adivinhar por quanto tempo o app ficou em background.

Route Guards Devem Esperar os Dois

Seus route guards devem esperar tanto a restauração de auth quanto o lifecycle ficar pronto antes de renderizar:

// 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>
  )
}

Desenvolvedores web não pensam nisso porque browsers não têm ciclo de vida de app. Desenvolvedores mobile não têm esse luxo. O Autotomy Expo Starter Pack já cuida do lifecycle out of the box para que seu app feito com vibe coding não quebre quando os usuários voltarem.