
App lifecycle events are critical for tasks like saving state, pausing animations, handling background tasks, and managing resources efficiently. Whether you’re building with Flutter or React Native, understanding the lifecycle of your app is essential for delivering a responsive, battery-efficient, and user-friendly experience. Mishandling lifecycle events can lead to lost data, wasted battery, or crashes when the app resumes.
In this post, you’ll learn how lifecycle events work in both Flutter and React Native, with comprehensive code examples covering real-world scenarios like media playback, authentication, data persistence, and background tasks.
What Are App Lifecycle Events?
App lifecycle events refer to transitions between an app’s active, inactive, background, and terminated states. The operating system triggers these events when users switch apps, receive phone calls, lock their devices, or close the app. Common use cases include:
- Saving unsaved data when the app goes to background
- Pausing video/audio playback to save battery
- Refreshing authentication tokens when the app resumes
- Releasing expensive resources (camera, location) on termination
- Reconnecting WebSocket connections on resume
- Analytics tracking for session duration
Flutter App Lifecycle Events
Flutter provides WidgetsBindingObserver to listen to app lifecycle changes. In Flutter 3.13+, you can also use the newer AppLifecycleListener for a more declarative approach.
Key States in Flutter
resumed– App is visible and responding to user inputinactive– App is in an inactive state (e.g., incoming phone call, app switcher)paused– App is not visible to the user (in background)detached– App is still hosted on the engine but detached from any viewhidden– App is hidden (new in Flutter 3.13+)
Complete Flutter Implementation
import 'package:flutter/material.dart';
import 'dart:async';
// Reusable lifecycle mixin for StatefulWidgets
mixin AppLifecycleMixin<T extends StatefulWidget> on State<T>, WidgetsBindingObserver {
AppLifecycleState? _lastLifecycleState;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lastLifecycleState = state;
switch (state) {
case AppLifecycleState.resumed:
onAppResumed();
break;
case AppLifecycleState.inactive:
onAppInactive();
break;
case AppLifecycleState.paused:
onAppPaused();
break;
case AppLifecycleState.detached:
onAppDetached();
break;
case AppLifecycleState.hidden:
onAppHidden();
break;
}
}
// Override these methods in your widget
void onAppResumed() {}
void onAppInactive() {}
void onAppPaused() {}
void onAppDetached() {}
void onAppHidden() {}
}
// Example: Video Player with Lifecycle Management
class VideoPlayerScreen extends StatefulWidget {
final String videoUrl;
const VideoPlayerScreen({required this.videoUrl});
@override
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen>
with WidgetsBindingObserver, AppLifecycleMixin {
late VideoPlayerController _controller;
bool _wasPlayingBeforePause = false;
Duration _savedPosition = Duration.zero;
@override
void initState() {
super.initState();
_initializePlayer();
}
Future<void> _initializePlayer() async {
_controller = VideoPlayerController.network(widget.videoUrl);
await _controller.initialize();
setState(() {});
}
@override
void onAppPaused() {
// Save playback state and pause
_wasPlayingBeforePause = _controller.value.isPlaying;
_savedPosition = _controller.value.position;
if (_controller.value.isPlaying) {
_controller.pause();
}
debugPrint('Video paused at ${_savedPosition.inSeconds}s');
}
@override
void onAppResumed() {
// Restore playback if it was playing before
if (_wasPlayingBeforePause) {
_controller.seekTo(_savedPosition);
_controller.play();
}
debugPrint('Video resumed');
}
@override
void onAppInactive() {
// Optionally pause during incoming calls
if (_controller.value.isPlaying) {
_wasPlayingBeforePause = true;
_controller.pause();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Video Player')),
body: _controller.value.isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
)
: const Center(child: CircularProgressIndicator()),
);
}
}
// Example: Form with Auto-Save on Background
class EditProfileScreen extends StatefulWidget {
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen>
with WidgetsBindingObserver, AppLifecycleMixin {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _bioController = TextEditingController();
bool _hasUnsavedChanges = false;
@override
void initState() {
super.initState();
_loadSavedDraft();
// Track changes
_nameController.addListener(_onChanged);
_bioController.addListener(_onChanged);
}
void _onChanged() {
if (!_hasUnsavedChanges) {
setState(() => _hasUnsavedChanges = true);
}
}
Future<void> _loadSavedDraft() async {
final prefs = await SharedPreferences.getInstance();
final savedName = prefs.getString('draft_name');
final savedBio = prefs.getString('draft_bio');
if (savedName != null) _nameController.text = savedName;
if (savedBio != null) _bioController.text = savedBio;
}
@override
void onAppPaused() {
// Auto-save draft when app goes to background
if (_hasUnsavedChanges) {
_saveDraft();
}
}
@override
void onAppDetached() {
// Final save attempt before termination
_saveDraft();
}
Future<void> _saveDraft() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('draft_name', _nameController.text);
await prefs.setString('draft_bio', _bioController.text);
debugPrint('Draft saved');
}
Future<void> _clearDraft() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('draft_name');
await prefs.remove('draft_bio');
}
Future<void> _submitForm() async {
if (_formKey.currentState?.validate() ?? false) {
// Submit to API
await profileService.updateProfile(
name: _nameController.text,
bio: _bioController.text,
);
// Clear draft after successful save
await _clearDraft();
setState(() => _hasUnsavedChanges = false);
Navigator.of(context).pop();
}
}
@override
void dispose() {
_nameController.dispose();
_bioController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Edit Profile'),
actions: [
if (_hasUnsavedChanges)
const Padding(
padding: EdgeInsets.all(16),
child: Icon(Icons.edit, size: 16),
),
],
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
validator: (v) => v?.isEmpty ?? true ? 'Required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _bioController,
decoration: const InputDecoration(labelText: 'Bio'),
maxLines: 3,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _submitForm,
child: const Text('Save'),
),
],
),
),
);
}
}
// Flutter 3.13+ AppLifecycleListener approach
class ModernLifecycleWidget extends StatefulWidget {
@override
State<ModernLifecycleWidget> createState() => _ModernLifecycleWidgetState();
}
class _ModernLifecycleWidgetState extends State<ModernLifecycleWidget> {
late final AppLifecycleListener _lifecycleListener;
@override
void initState() {
super.initState();
_lifecycleListener = AppLifecycleListener(
onResume: () => debugPrint('App resumed'),
onInactive: () => debugPrint('App inactive'),
onHide: () => debugPrint('App hidden'),
onShow: () => debugPrint('App shown'),
onPause: () => debugPrint('App paused'),
onRestart: () => debugPrint('App restarted'),
onDetach: () => debugPrint('App detached'),
onExitRequested: () async {
// Return AppExitResponse.exit or .cancel
return AppExitResponse.exit;
},
);
}
@override
void dispose() {
_lifecycleListener.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
React Native App Lifecycle Events
In React Native, the AppState API allows you to track app state changes. Combined with hooks, you can create reusable lifecycle management.
Key States in React Native
active– App is running in the foregroundbackground– App is in the backgroundinactive– Transitory state (e.g., switching apps on iOS, incoming call)
Complete React Native Implementation
// hooks/useAppLifecycle.ts
import { useEffect, useRef, useCallback } from 'react';
import { AppState, AppStateStatus } from 'react-native';
interface LifecycleCallbacks {
onActive?: () => void;
onBackground?: () => void;
onInactive?: () => void;
onForeground?: () => void; // Triggered when coming back from background
}
export function useAppLifecycle(callbacks: LifecycleCallbacks) {
const appState = useRef(AppState.currentState);
const { onActive, onBackground, onInactive, onForeground } = callbacks;
const handleAppStateChange = useCallback((nextAppState: AppStateStatus) => {
const previousState = appState.current;
// Detect foreground transition
if (
previousState.match(/inactive|background/) &&
nextAppState === 'active'
) {
onForeground?.();
}
// Call specific state handlers
switch (nextAppState) {
case 'active':
onActive?.();
break;
case 'background':
onBackground?.();
break;
case 'inactive':
onInactive?.();
break;
}
appState.current = nextAppState;
}, [onActive, onBackground, onInactive, onForeground]);
useEffect(() => {
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription.remove();
};
}, [handleAppStateChange]);
return appState.current;
}
// hooks/useAppFocus.ts - Simple hook for focus/blur detection
import { useEffect, useState } from 'react';
import { AppState, AppStateStatus } from 'react-native';
export function useAppFocus(): boolean {
const [isFocused, setIsFocused] = useState(AppState.currentState === 'active');
useEffect(() => {
const subscription = AppState.addEventListener(
'change',
(state: AppStateStatus) => {
setIsFocused(state === 'active');
}
);
return () => subscription.remove();
}, []);
return isFocused;
}
// Example: Video Player Component
import React, { useRef, useEffect, useState } from 'react';
import { View, StyleSheet } from 'react-native';
import Video, { VideoRef } from 'react-native-video';
import { useAppLifecycle } from '../hooks/useAppLifecycle';
interface VideoPlayerProps {
source: string;
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ source }) => {
const videoRef = useRef<VideoRef>(null);
const [isPaused, setIsPaused] = useState(false);
const wasPlayingRef = useRef(false);
const positionRef = useRef(0);
useAppLifecycle({
onBackground: () => {
// Save state and pause
wasPlayingRef.current = !isPaused;
if (!isPaused) {
setIsPaused(true);
}
console.log('Video paused, was playing:', wasPlayingRef.current);
},
onForeground: () => {
// Restore playback if it was playing
if (wasPlayingRef.current) {
setIsPaused(false);
// Seek to saved position
videoRef.current?.seek(positionRef.current);
}
console.log('Video resumed');
},
onInactive: () => {
// Pause during phone calls, etc.
if (!isPaused) {
wasPlayingRef.current = true;
setIsPaused(true);
}
},
});
const onProgress = (data: { currentTime: number }) => {
positionRef.current = data.currentTime;
};
return (
<View style={styles.container}>
<Video
ref={videoRef}
source={{ uri: source }}
style={styles.video}
paused={isPaused}
onProgress={onProgress}
resizeMode="contain"
/>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
video: { width: '100%', height: 300 },
});
// Example: Form with Auto-Save
import React, { useState, useEffect, useCallback } from 'react';
import { View, TextInput, Button, StyleSheet, Alert } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAppLifecycle } from '../hooks/useAppLifecycle';
const DRAFT_KEY = 'profile_draft';
export const EditProfileScreen: React.FC = () => {
const [name, setName] = useState('');
const [bio, setBio] = useState('');
const [hasChanges, setHasChanges] = useState(false);
// Load saved draft on mount
useEffect(() => {
const loadDraft = async () => {
try {
const draft = await AsyncStorage.getItem(DRAFT_KEY);
if (draft) {
const { name: savedName, bio: savedBio } = JSON.parse(draft);
setName(savedName || '');
setBio(savedBio || '');
}
} catch (error) {
console.error('Failed to load draft:', error);
}
};
loadDraft();
}, []);
const saveDraft = useCallback(async () => {
if (!hasChanges) return;
try {
await AsyncStorage.setItem(
DRAFT_KEY,
JSON.stringify({ name, bio })
);
console.log('Draft saved');
} catch (error) {
console.error('Failed to save draft:', error);
}
}, [name, bio, hasChanges]);
const clearDraft = async () => {
await AsyncStorage.removeItem(DRAFT_KEY);
};
useAppLifecycle({
onBackground: () => {
// Auto-save when going to background
saveDraft();
},
onForeground: () => {
// Could refresh data or check for updates
console.log('App returned to foreground');
},
});
const handleNameChange = (text: string) => {
setName(text);
setHasChanges(true);
};
const handleBioChange = (text: string) => {
setBio(text);
setHasChanges(true);
};
const handleSubmit = async () => {
try {
// Submit to API
await profileApi.update({ name, bio });
// Clear draft after successful save
await clearDraft();
setHasChanges(false);
Alert.alert('Success', 'Profile updated!');
} catch (error) {
Alert.alert('Error', 'Failed to update profile');
}
};
return (
<View style={styles.container}>
<TextInput
style={styles.input}
value={name}
onChangeText={handleNameChange}
placeholder="Name"
/>
<TextInput
style={[styles.input, styles.bioInput]}
value={bio}
onChangeText={handleBioChange}
placeholder="Bio"
multiline
/>
<Button title="Save" onPress={handleSubmit} />
</View>
);
};
// Example: Session Management with Lifecycle
import { useEffect, useRef } from 'react';
import { useAppLifecycle } from '../hooks/useAppLifecycle';
import { useAuth } from '../contexts/AuthContext';
export function useSessionManager() {
const { token, refreshToken, logout } = useAuth();
const backgroundTime = useRef<number | null>(null);
const SESSION_TIMEOUT = 5 * 60 * 1000; // 5 minutes
useAppLifecycle({
onBackground: () => {
backgroundTime.current = Date.now();
console.log('Session: app backgrounded');
},
onForeground: async () => {
if (!backgroundTime.current) return;
const elapsed = Date.now() - backgroundTime.current;
backgroundTime.current = null;
if (elapsed > SESSION_TIMEOUT) {
// Session expired - require re-authentication
console.log('Session expired after', elapsed / 1000, 'seconds');
await logout();
return;
}
// Refresh token if needed
try {
await refreshToken();
console.log('Token refreshed after foreground');
} catch (error) {
console.error('Token refresh failed:', error);
await logout();
}
},
});
}
// Example: Analytics Tracking
import { useRef } from 'react';
import { useAppLifecycle } from '../hooks/useAppLifecycle';
import analytics from '@react-native-firebase/analytics';
export function useAnalyticsLifecycle(screenName: string) {
const sessionStart = useRef<number>(Date.now());
const activeTime = useRef<number>(0);
const lastActiveTime = useRef<number>(Date.now());
useAppLifecycle({
onActive: () => {
lastActiveTime.current = Date.now();
analytics().logScreenView({ screen_name: screenName });
},
onBackground: () => {
// Track time spent
activeTime.current += Date.now() - lastActiveTime.current;
analytics().logEvent('screen_time', {
screen_name: screenName,
duration_seconds: Math.round(activeTime.current / 1000),
});
},
});
}
Platform Differences
| Behavior | iOS | Android |
|---|---|---|
| Inactive state | Common (app switcher, calls) | Less common |
| Background time limit | ~3 minutes | Varies by manufacturer |
| App termination notification | Not guaranteed | Not guaranteed |
| Resume after long background | May restart app | May restart app |
Real-World Use Cases
| Use Case | Lifecycle Event | Implementation |
|---|---|---|
| Pause video playback | paused, background | Save position, pause player |
| Save draft data | paused, inactive | Persist to AsyncStorage/SharedPrefs |
| Re-authenticate session | resumed, active | Check token expiry, refresh if needed |
| Track session duration | resumed, background | Log start/end times to analytics |
| Reconnect WebSocket | resumed, active | Check connection, reconnect if closed |
| Release camera/location | paused, background | Stop services, release resources |
Common Mistakes to Avoid
Not Removing Listeners
Always unsubscribe from lifecycle listeners in dispose (Flutter) or useEffect cleanup (React Native) to avoid memory leaks.
Relying on Detached/Terminated Events
The OS may kill your app without calling termination handlers. Save critical data on every background transition, not just on termination.
Heavy Operations in Lifecycle Handlers
Don’t perform heavy synchronous operations in lifecycle handlers. The OS may terminate your app if background work takes too long. Use background tasks for heavy operations.
Not Testing on Both Platforms
iOS and Android have different lifecycle behaviors. Test thoroughly on both, including scenarios like incoming calls, app switcher, and device lock.
Ignoring Edge Cases
Handle rapid state changes (quick background/foreground) gracefully. Use debouncing or state flags to prevent duplicate operations.
Best Practices
- Always unsubscribe listeners to avoid memory leaks
- Don’t rely on lifecycle events for critical data saves—use them to supplement user actions
- Test thoroughly across Android/iOS as lifecycle behavior differs
- Combine with background tasks for operations that need to complete
- Use debouncing for rapid state transitions
- Log lifecycle events during development for debugging
Final Thoughts
Understanding app lifecycle events is key to building responsive, efficient, and user-friendly mobile apps. Whether you’re using Flutter or React Native, handling lifecycle transitions properly helps improve performance, preserve user data, and provide a seamless experience.
Make lifecycle awareness part of your development checklist—especially in apps that handle media, background sync, authentication, or long user sessions. The examples in this article provide reusable patterns you can adapt for your specific needs.
For more on state management that works well with lifecycle events, see our Flutter state management guide or React Native state management comparison. For official documentation, check the React Native AppState docs and Flutter WidgetsBindingObserver documentation.