“Kenapa Aplikasi Saya Menampilkan Layar Kosong Setelah Makan Siang?”

Anda membangun aplikasi Expo yang bagus di Cursor. Claude Code menghasilkan auth flow, dashboard, dan settings screen. Anda merilisnya. Pengguna mengunduhnya. Lalu DM mulai berdatangan:

  • “Kenapa aplikasi ini menampilkan layar kosong saat saya buka lagi setelah makan siang?”
  • “Kenapa saya harus login lagi setiap pagi?”
  • “Kenapa data saya hilang setelah saya pindah balik dari Instagram?”

Anda tidak membangun aplikasi yang rusak. Anda membangun mentalitas web app di dalam container native app. Browser tidak punya app lifecycle. Mobile app punya. Saat pengguna menekan tombol home, aplikasi Anda tidak mati - aplikasi itu tidur. Saat mereka mengetuk ikon Anda enam jam kemudian, aplikasi itu bangun lagi. Dan semua hal yang benar enam jam lalu bisa saja sekarang sudah tidak benar.

Masalah Resume

Saat pengguna kembali setelah berjam-jam pergi, Anda harus mengambil keputusan. Apakah session masih valid? Apakah datanya masih cukup fresh? Kebanyakan aplikasi hasil vibe coding menjawab ini secara ad hoc, per screen, dengan hasil yang tidak konsisten. Satu screen melakukan re-fetch. Screen lain menampilkan data stale. Screen ketiga crash karena berasumsi session masih ada.

Perbaikannya: turunkan resumeStatus dari waktu yang berlalu sejak pengguna terakhir aktif, lalu biarkan seluruh aplikasi bereaksi terhadapnya:

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

Empat state, masing-masing dengan arti yang jelas:

  • fresh: Launch pertama. Tidak ada session sebelumnya.
  • resumed: Kembali dalam 5 menit. Data kemungkinan masih aman.
  • stale: Pergi lebih dari 5 menit. Saatnya re-fetch data penting.
  • expired: Pergi lebih dari 24 jam. Perlakukan seperti launch baru.

Lifecycle Provider

Subscribe ke AppState sekali saja. Expose status turunannya ke seluruh aplikasi:

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

Insight utamanya: auth, updates, data refresh - setiap provider yang peduli pada proses resume membaca resumeStatus dari satu context ini. Tidak ada screen yang menulis AppState.addEventListener sendiri. Tidak ada feature yang menebak berapa lama aplikasi berada di background.

Route Guards Menunggu Keduanya

Route guards Anda harus menunggu keduanya: pemulihan auth dan lifecycle readiness sebelum melakukan render:

// 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 developer jarang memikirkan ini karena browser tidak punya app lifecycle. Mobile developer tidak punya kemewahan itu. Autotomy Expo Starter Pack menangani lifecycle management secara bawaan sehingga aplikasi hasil vibe coding Anda tidak rusak saat pengguna kembali.