“Pourquoi mon app affiche un écran vide après le déjeuner ?”
Vous avez construit une belle app Expo dans Cursor. Claude Code a généré le flow d’auth, le dashboard, l’écran de réglages. Vous l’avez livrée. Les utilisateurs l’ont téléchargée. Puis les DM ont commencé :
- “Pourquoi l’app affiche un écran vide quand je la rouvre après le déjeuner ?”
- “Pourquoi je dois me reconnecter tous les matins ?”
- “Pourquoi mes données ont disparu quand je suis revenu depuis Instagram ?”
Vous n’avez pas construit une app cassée. Vous avez mis une mentalité d’app web dans un conteneur d’app native. Les navigateurs n’ont pas de cycle de vie d’application. Les apps mobiles, si. Quand un utilisateur appuie sur le bouton d’accueil, votre app ne meurt pas : elle dort. Quand il touche votre icône six heures plus tard, elle se réveille. Et tout ce qui était vrai il y a six heures est peut-être faux maintenant.
Le problème de la reprise
Quand un utilisateur revient après plusieurs heures d’absence, vous devez prendre des décisions. La session est-elle encore valide ? Les données sont-elles encore assez fraîches ? La plupart des apps vibe codées répondent à ces questions au cas par cas, écran par écran, avec des résultats incohérents. Un écran relance un fetch. Un autre affiche des données périmées. Un troisième plante parce qu’il suppose que la session existe toujours.
La solution : dériver un resumeStatus à partir du temps écoulé depuis la dernière activité de l’utilisateur, puis laisser toute l’app réagir à cet état :
// 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'
}
Quatre états, chacun avec une signification claire :
- fresh : Premier lancement. Aucune session précédente.
- resumed : Retour en moins de 5 minutes. Les données sont probablement encore correctes.
- stale : Absence de plus de 5 minutes. Il est temps de recharger les données importantes.
- expired : Absence de plus de 24 heures. À traiter comme un nouveau lancement.
Le provider de cycle de vie
Abonnez-vous à AppState une seule fois. Exposez l’état dérivé partout :
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>
)
}
L’idée clé : auth, updates, data refresh, tous les providers concernés par la reprise lisent resumeStatus depuis ce contexte unique. Aucun écran n’écrit son propre AppState.addEventListener. Aucune feature ne devine combien de temps l’app est restée en arrière-plan.
Les route guards attendent les deux
Vos route guards doivent attendre à la fois la restauration de l’auth et la disponibilité du cycle de vie avant de rendre quoi que ce soit :
// 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>
)
}
Les développeurs web n’y pensent pas parce que les navigateurs n’ont pas de cycle de vie applicatif. Les développeurs mobiles n’ont pas ce luxe. L’Autotomy Expo Starter Pack gère ce cycle de vie dès le départ pour que votre app vibe codée ne casse pas quand les utilisateurs reviennent.