Tracking whether your app is in the foreground or background seems straightforward—until it isn't. 🤯 Network calls need pausing, timers go haywire, and analytics events become noisy. React Native ships the AppState
API, but its low‑level nature can quickly lead to verbose, error‑prone code.
In this article we’ll craft useAppVisibility
, a production‑ready Hook that:
- Exposes typed foreground/background flags.
- Emits callbacks (onForeground/onBackground) so you don’t sprinkle listeners everywhere.
- Tracks durations—how long the user stayed away or active.
- Handles edge states (
unknown
,extension
). - Plays nicely with TypeScript and removes every event listener on unmount.
Ready? Let’s dive in. 🚀
1. Problems with naïve AppState
usage
import { AppState } from 'react-native';
// 🤢 Re‑creating listeners inside components,
// hard‑to‑test, no duration tracking, forget cleanup…
- You manually juggle three states:
active
,inactive
,background
. - There’s no built‑in notion of how long a state lasted.
- Forgetting to call
remove()
leaks memory. - Regex checks (
/inactive|background/
) make TypeScript sad.
2. The enhanced useAppVisibility
Hook
Below is the drop‑in replacement. Copy‑paste, then we’ll dissect the magic.
import { useEffect, useRef, useState, useCallback } from 'react';
import { AppState, AppStateStatus } from 'react-native';
export interface UseAppVisibilityOptions {
/** Fires on *every* state change */
onAppStateChange?: (state: AppStateStatus) => void;
/** Fires when leaving foreground. `since` = ms spent in foreground */
onBackground?: (state: AppStateStatus, since: number) => void;
/** Fires when returning to foreground. `since` = ms spent in background */
onForeground?: (state: AppStateStatus, since: number) => void;
/** Collect foreground/background durations (default: true) */
trackDurations?: boolean;
}
export interface AppVisibilityState {
appState: AppStateStatus;
isBackground: boolean;
isForeground: boolean;
lastBackgroundAt?: number;
lastForegroundAt?: number;
backgroundDuration?: number;
foregroundDuration?: number;
}
export const useAppVisibility = ({
onAppStateChange,
onBackground,
onForeground,
trackDurations = true,
}: UseAppVisibilityOptions = {}): AppVisibilityState => {
const [state, setState] = useState<AppVisibilityState>(() => {
const init = AppState.currentState;
const now = Date.now();
return {
appState: init,
isBackground: init !== 'active',
isForeground: init === 'active',
lastBackgroundAt: init !== 'active' ? now : undefined,
lastForegroundAt: init === 'active' ? now : undefined,
};
});
const appStateRef = useRef<AppStateStatus>(state.appState);
const handleChange = useCallback(
(next: AppStateStatus) => {
if (appStateRef.current === next) return;
const now = Date.now();
const wasForeground = appStateRef.current === 'active';
const willBeForeground = next === 'active';
const backgroundDuration =
wasForeground && trackDurations && state.lastForegroundAt
? now - state.lastForegroundAt
: undefined;
const foregroundDuration =
!wasForeground && willBeForeground && trackDurations && state.lastBackgroundAt
? now - state.lastBackgroundAt
: undefined;
setState((prev) => ({
...prev,
appState: next,
isBackground: !willBeForeground,
isForeground: willBeForeground,
lastBackgroundAt: wasForeground ? now : prev.lastBackgroundAt,
lastForegroundAt: willBeForeground ? now : prev.lastForegroundAt,
backgroundDuration,
foregroundDuration,
}));
onAppStateChange?.(next);
if (wasForeground && !willBeForeground) onBackground?.(next, backgroundDuration ?? 0);
if (!wasForeground && willBeForeground) onForeground?.(next, foregroundDuration ?? 0);
appStateRef.current = next;
},
[
onAppStateChange,
onBackground,
onForeground,
trackDurations,
state.lastForegroundAt,
state.lastBackgroundAt,
]
);
useEffect(() => {
const sub = AppState.addEventListener('change', handleChange);
return () => sub.remove();
}, [handleChange]);
return state;
};
2.1 What changed & why ✨
Improvement | Why it matters |
---|---|
Typed API (UseAppVisibilityOptions , AppVisibilityState ) |
Auto‑complete & compile‑time safety |
Durations (backgroundDuration , foregroundDuration ) |
Perfect for analytics & auto‑pause logic |
Predictable transitions (no regex) | Simpler logic, fewer edge bugs |
Callbacks include since |
Avoid ref math in every screen |
Cleanup on unmount | Prevent memory leaks |
Edge states aware | Gracefully handles unknown /extension on tvOS / visionOS |
3. Using the hook in practice
- Pause media when backgrounded.
- Throttle network requests to avoid unnecessary load.
const PlayerScreen = () => {
const { isBackground, foregroundDuration } = useAppVisibility({
onBackground: (_, since) => console.log(`👋 User quit after ${since} ms`),
});
useEffect(() => {
if (isBackground) Audio.pause();
else Audio.resume();
}, [isBackground]);
return <VideoPlayer />;
};
Pause media, throttle requests, refresh data—without scattering listeners.
- Track analytics with durations.
function useAnalytics() {
const { foregroundDuration, backgroundDuration } = useAppVisibility({
onForeground: (_, since) => console.log(`👋 User returned after ${since} ms`),
onBackground: (_, since) => console.log(`👋 User left after ${since} ms`),
});
useEffect(() => {
if (foregroundDuration) {
Analytics.track('app_foreground', { duration: foregroundDuration });
}
if (backgroundDuration) {
Analytics.track('app_background', { duration: backgroundDuration });
}
}, [foregroundDuration, backgroundDuration]);
return null;
}
const AnalyticsScreen = () => {
useAnalytics();
return <Dashboard />;
};
- Handle WebSocket connections.
const useWebSocket = () => {
const { isForeground } = useAppVisibility({
onForeground: () => WebSocket.connect(),
onBackground: () => WebSocket.disconnect(),
});
return { isForeground };
};
const ChatScreen = () => {
const { isForeground } = useWebSocket();
return <ChatRoom isConnected={isForeground} />;
};
- Auto‑logout after 5 minutes in background.
const useAutoLogout = () => {
const { isBackground, lastBackgroundAt } = useAppVisibility({
onBackground: () => console.log('User went to background'),
});
const [isLoggedIn, setIsLoggedIn] = useState(true);
useEffect(() => {
if (isBackground && lastBackgroundAt) {
const timeout = setTimeout(
() => {
setIsLoggedIn(false);
console.log('User auto‑logged out after 5 minutes in background');
},
5 * 60 * 1000
);
return () => clearTimeout(timeout);
}
}, [isBackground, lastBackgroundAt]);
return { isLoggedIn };
};
const LoginScreen = () => {
const { isLoggedIn } = useAutoLogout();
if (!isLoggedIn) return <LoginForm />;
return <HomeScreen />;
};
- Refresh JWT if user returns after >30 minutes.
const useJwtRefresh = () => {
const { isForeground, lastBackgroundAt } = useAppVisibility({
onForeground: () => console.log('User returned to foreground'),
});
useEffect(() => {
if (isForeground && lastBackgroundAt) {
const timeAway = Date.now() - lastBackgroundAt;
if (timeAway > 30 * 60 * 1000) {
// Refresh JWT logic here
console.log('Refreshing JWT after 30 minutes away');
}
}
}, [isForeground, lastBackgroundAt]);
};
const AuthScreen = () => {
useJwtRefresh();
return <Profile />;
};
4. Real‑world scenarios
- Auto‑logout after 5 minutes in background.
- Refresh JWT if user returns after >30 minutes.
-
Send analytics:
app_background
/app_foreground
events with durations. - Resume WebSocket connection only when foregrounded.
5. Testing Tips 🧪
- In Jest, mock
AppState.addEventListener
and fire callbacks. - On iOS Simulator,
Cmd ⇧ H
toggles background. - With Expo, use
expo‑dev‑client
to inspect state changes.
6. Caveats & gotchas
-
inactive
on iOS can fire when showing the control center—treat as background if you need to mute audio. - On Android, orientation change does not trigger background—handle inside
useSafeAreaInsets
if needed. - Avoid heavy work inside the Hook; offload to callbacks.
7. Conclusion
Handling app visibility correctly is crucial for performance and user experience. With the enhanced useAppVisibility
you get type safety, duration metrics, and clean callbacks—all in ~90 lines of code.
Enjoyed this? Follow me on dev.to and Medium for more React & React Native deep‑dives.
Top comments (0)