“Warum zeigt meine App nach der Mittagspause einen leeren Screen?”

Du hast in Cursor eine schoene Expo-App gebaut. Claude Code hat den Auth-Flow, das Dashboard und den Settings-Screen generiert. Du hast sie ausgeliefert. Nutzer haben sie heruntergeladen. Dann kamen die DMs:

  • “Warum zeigt die App einen leeren Screen, wenn ich sie nach der Mittagspause wieder oeffne?”
  • “Warum muss ich mich jeden Morgen erneut einloggen?”
  • “Warum sind meine Daten verschwunden, nachdem ich von Instagram zurueckgewechselt bin?”

Du hast keine kaputte App gebaut. Du hast eine Web-App-Denkweise in einen nativen App-Container gepackt. Browser haben keinen App Lifecycle. Mobile Apps schon. Wenn ein Nutzer den Home-Button drueckt, stirbt deine App nicht - sie schlaeft. Wenn er sechs Stunden spaeter auf dein Icon tippt, wacht sie wieder auf. Und alles, was vor sechs Stunden noch wahr war, kann inzwischen eine Luege sein.

Das Resume-Problem

Wenn ein Nutzer nach mehreren Stunden zurueckkommt, musst du Entscheidungen treffen. Ist die Session noch gueltig? Sind die Daten noch frisch genug? Die meisten vibe-codierten Apps beantworten das ad hoc, pro Screen, mit inkonsistenten Ergebnissen. Ein Screen laedt neu. Ein anderer zeigt veraltete Daten. Ein dritter crasht, weil er davon ausgeht, dass die Session noch existiert.

Die Loesung: Leite einen resumeStatus aus der verstrichenen Zeit seit der letzten Aktivitaet des Nutzers ab und lass die ganze App darauf reagieren:

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

Vier Zustaende mit jeweils klarer Bedeutung:

  • fresh: Erster Start. Keine vorherige Session.
  • resumed: Rueckkehr innerhalb von 5 Minuten. Die Daten sind wahrscheinlich noch in Ordnung.
  • stale: 5+ Minuten weg. Zeit, zentrale Daten neu zu laden.
  • expired: 24+ Stunden weg. Behandle es wie einen frischen Start.

Der Lifecycle Provider

Abonniere AppState genau einmal. Stelle den abgeleiteten Status ueberall bereit:

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

Die zentrale Erkenntnis: Auth, Updates, Data Refresh - jeder Provider, der sich fuer das Wiederaufnehmen der App interessiert, liest resumeStatus aus genau diesem einen Context. Kein Screen schreibt seinen eigenen AppState.addEventListener. Kein Feature raet, wie lange die App im Background war.

Route Guards warten auf beides

Deine Route Guards sollten auf beides warten: auf das Wiederherstellen der Auth-Session und auf die Bereitschaft des Lifecycle-Status, bevor sie rendern:

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

Web-Entwickler denken selten daran, weil Browser keinen App Lifecycle haben. Mobile Entwickler koennen sich diesen Luxus nicht leisten. Das Autotomy Expo Starter Pack kuemmert sich ab Werk um Lifecycle-Management, damit deine vibe-codierte App nicht auseinanderfaellt, wenn Nutzer zurueckkommen.