DartFlutterReact Native

Understanding App Lifecycle Events in Flutter and React Native

Understanding App Lifecycle Events in Flutter and React Native

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 input
  • inactive – 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 view
  • hidden – 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 foreground
  • background – App is in the background
  • inactive – 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.

Leave a Comment