“¿Por qué mi app muestra una pantalla en blanco después de comer?”
Construiste una app Expo preciosa en Cursor. Claude Code generó el flujo de auth, el dashboard, la pantalla de settings. La lanzaste. Los usuarios la descargaron. Y entonces empezaron los DMs:
- “¿Por qué la app muestra una pantalla en blanco cuando la abro después de comer?”
- “¿Por qué tengo que volver a iniciar sesión cada mañana?”
- “¿Por qué desaparecieron mis datos cuando volví desde Instagram?”
No construiste una app rota. Construiste una mentalidad de web app dentro de un contenedor de app nativa. Los browsers no tienen ciclo de vida de app. Las apps móviles sí. Cuando un usuario pulsa el botón de inicio, tu app no muere: se duerme. Cuando toca tu icono seis horas después, se despierta. Y todo lo que era verdad hace seis horas ahora es mentira.
El problema al volver
Cuando un usuario regresa tras pasar horas fuera, tienes decisiones que tomar. ¿La sesión sigue siendo válida? ¿Los datos siguen siendo lo bastante frescos? La mayoría de las apps hechas con vibe coding responden esto de forma ad hoc, pantalla por pantalla, con resultados inconsistentes. Una pantalla hace re-fetch. Otra muestra datos stale. Una tercera se cae porque asume que la sesión sigue ahí.
La solución: deriva un resumeStatus a partir del tiempo transcurrido desde la última vez que el usuario estuvo activo y deja que toda la app reaccione a él:
// 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'
}
Cuatro estados, cada uno con un significado claro:
- fresh: Primer arranque. Sin sesión previa.
- resumed: De vuelta en menos de 5 minutos. Los datos probablemente siguen bien.
- stale: Ausente 5+ minutos. Hora de volver a pedir los datos clave.
- expired: Ausente 24+ horas. Trátalo como un arranque nuevo.
El Lifecycle Provider
Suscríbete a AppState una sola vez. Expón el estado derivado en todas partes:
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>
)
}
La idea clave: auth, updates y refresh de datos; cualquier provider que se preocupe por la reanudación lee resumeStatus desde este único contexto. Ninguna pantalla define su propio AppState.addEventListener. Ninguna feature adivina cuánto tiempo estuvo la app en background.
Los route guards deben esperar ambas cosas
Tus route guards deberían esperar tanto la restauración de auth como que el lifecycle esté listo 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>
)
}
Los desarrolladores web no piensan en esto porque los browsers no tienen ciclo de vida de app. Los desarrolladores mobile no tienen ese lujo. El Autotomy Expo Starter Pack resuelve el lifecycle out of the box para que tu app hecha con vibe coding no se rompa cuando los usuarios vuelvan.