Skip to content

Commit 7104c5e

Browse files
Add useAppState() hook for tracking app foreground/background state
Summary: Add a new `useAppState()` React hook that lets components subscribe to app state changes, following the same pattern as the existing `useColorScheme()` and `useWindowDimensions()` hooks. ### Problem Currently, tracking app state requires manually managing AppState event listeners and local state: ```js // Before - verbose boilerplate function MyComponent() { const [appState, setAppState] = useState(AppState.currentState); useEffect(() => { const sub = AppState.addEventListener('change', setAppState); return () => sub.remove(); }, []); } ``` ### Solution A single-line hook using React's `useSyncExternalStore`: ```js // After - clean and simple import { useAppState } from 'react-native'; function MyComponent() { const appState = useAppState(); // Returns: 'active' | 'background' | 'inactive' | 'unknown' } ``` ### Common use cases for mobile apps - **Refresh data** when the app returns to foreground - **Pause/resume media** when the app is backgrounded - **Save drafts** when the user leaves the app - **Disable network polling** while in background to save battery - **Track analytics** for session foreground/background time ### What's included - `useAppState.js` - Hook implementation using `useSyncExternalStore` - `useAppState.d.ts` - TypeScript declarations - Export from `react-native` package (index.js + index.js.flow) - Jest mock for testing (`useAppState` returns `'active'` by default) - Unit tests following the `useColorScheme` test pattern ## Changelog: [GENERAL] [ADDED] - Add useAppState() hook for subscribing to app foreground/background state changes X-link: #56386 Reviewed By: javache Differential Revision: D100746279 Pulled By: Abbondanzo fbshipit-source-id: a8e41e8806eac1030636a0d0fd713fba732fe417
1 parent e10d6ba commit 7104c5e

8 files changed

Lines changed: 239 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import type {AppStateStatus} from 'react-native';
12+
13+
const useAppState = jest.fn(() => 'active') as JestMockFn<[], AppStateStatus>;
14+
15+
export default useAppState;

‎packages/jest-preset/jest/setup.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ mock(
9393
// $FlowFixMe[incompatible-type] - `./mocks/AppState` is incomplete.
9494
'm#./mocks/AppState',
9595
);
96+
mock(
97+
// $FlowFixMe[cannot-resolve-module]
98+
'm#react-native/Libraries/AppState/useAppState',
99+
// $FlowFixMe[react-rule-hook-incompatible]
100+
'm#./mocks/useAppState',
101+
);
96102
mock(
97103
// $FlowFixMe[cannot-resolve-module]
98104
'm#react-native/Libraries/BatchedBridge/NativeModules',
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import View from '../../Components/View/View';
14+
import AppState, {type AppStateStatus} from '../AppState';
15+
import useAppState from '../useAppState';
16+
import * as Fantom from '@react-native/fantom';
17+
import * as React from 'react';
18+
19+
describe('useAppState', () => {
20+
const originalAddEventListener = AppState.addEventListener;
21+
const originalCurrentState = AppState.currentState;
22+
23+
let observed: AppStateStatus | void;
24+
25+
afterEach(() => {
26+
// $FlowFixMe[cannot-write]
27+
AppState.addEventListener = originalAddEventListener;
28+
AppState.currentState = originalCurrentState;
29+
observed = undefined;
30+
});
31+
32+
function mockSubscription() {
33+
const handlers: Array<(AppStateStatus) => void> = [];
34+
const remove = jest.fn();
35+
const addEventListener = jest.fn(
36+
(type: string, handler: AppStateStatus => void) => {
37+
handlers.push(handler);
38+
return {remove};
39+
},
40+
);
41+
// $FlowFixMe[cannot-write]
42+
AppState.addEventListener = addEventListener;
43+
return {addEventListener, handlers, remove};
44+
}
45+
46+
function renderHook() {
47+
function TestComponent() {
48+
observed = useAppState();
49+
return <View />;
50+
}
51+
52+
const root = Fantom.createRoot();
53+
Fantom.runTask(() => {
54+
root.render(<TestComponent />);
55+
});
56+
return root;
57+
}
58+
59+
for (const state of ['active', 'background', 'inactive', 'extension']) {
60+
it(`returns '${state}' when AppState reports '${state}'`, () => {
61+
mockSubscription();
62+
AppState.currentState = state;
63+
64+
const root = renderHook();
65+
66+
expect(observed).toBe(state);
67+
68+
root.destroy();
69+
});
70+
}
71+
72+
it("returns 'unknown' when the current state is not yet known", () => {
73+
mockSubscription();
74+
AppState.currentState = null;
75+
76+
const root = renderHook();
77+
78+
expect(observed).toBe('unknown');
79+
80+
root.destroy();
81+
});
82+
83+
it('subscribes to changes and re-renders with the new state', () => {
84+
const {addEventListener, handlers} = mockSubscription();
85+
AppState.currentState = 'active';
86+
87+
const root = renderHook();
88+
89+
expect(addEventListener.mock.calls.length).toBe(1);
90+
expect(addEventListener.mock.calls[0][0]).toBe('change');
91+
expect(typeof addEventListener.mock.calls[0][1]).toBe('function');
92+
expect(observed).toBe('active');
93+
94+
AppState.currentState = 'background';
95+
Fantom.runTask(() => {
96+
handlers.forEach(handler => handler('background'));
97+
});
98+
expect(observed).toBe('background');
99+
100+
root.destroy();
101+
});
102+
103+
it('unsubscribes when the component unmounts', () => {
104+
const {remove} = mockSubscription();
105+
AppState.currentState = 'active';
106+
107+
const root = renderHook();
108+
expect(observed).toBe('active');
109+
110+
root.destroy();
111+
Fantom.runWorkLoop();
112+
113+
expect(remove).toHaveBeenCalledTimes(1);
114+
});
115+
116+
it('throws when called outside of a component', () => {
117+
let error;
118+
try {
119+
// $FlowFixMe[react-rule-hook]
120+
useAppState();
121+
} catch (e) {
122+
error = e;
123+
}
124+
125+
expect(error).toBeInstanceOf(Error);
126+
});
127+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
*/
9+
10+
import type {AppStateStatus} from './AppState';
11+
12+
/**
13+
* `useAppState` is a React hook that returns the current app state.
14+
* The component will re-render whenever the app state changes.
15+
*
16+
* Returns one of: 'active', 'background', 'inactive', 'unknown', or 'extension'.
17+
*
18+
* @see https://reactnative.dev/docs/appstate
19+
*/
20+
export default function useAppState(): AppStateStatus;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
'use strict';
12+
13+
import type {AppStateStatus} from './AppState';
14+
15+
import AppState from './AppState';
16+
import {useSyncExternalStore} from 'react';
17+
18+
const subscribe = (onStoreChange: () => void) => {
19+
const subscription = AppState.addEventListener('change', onStoreChange);
20+
return () => subscription.remove();
21+
};
22+
23+
const getSnapshot = (): AppStateStatus => {
24+
const currentState = AppState.currentState;
25+
return currentState === 'active' ||
26+
currentState === 'background' ||
27+
currentState === 'inactive' ||
28+
currentState === 'extension'
29+
? currentState
30+
: 'unknown';
31+
};
32+
33+
/**
34+
* `useAppState` is a React hook that returns the current app state.
35+
*
36+
* The value will be one of:
37+
* - `active` - The app is running in the foreground
38+
* - `background` - The app is running in the background
39+
* - `inactive` - (iOS only) Transitioning between foreground and background
40+
* - `unknown` - The initial state before the app state is determined
41+
*
42+
* The hook automatically subscribes to app state changes and re-renders the
43+
* component when the state changes.
44+
*
45+
* Usage:
46+
* ```
47+
* function MyComponent() {
48+
* const appState = useAppState();
49+
*
50+
* useEffect(() => {
51+
* if (appState === 'active') {
52+
* // App came to foreground - refresh data
53+
* }
54+
* }, [appState]);
55+
*
56+
* return <Text>App is {appState}</Text>;
57+
* }
58+
* ```
59+
*
60+
* See https://reactnative.dev/docs/appstate
61+
*/
62+
export default function useAppState(): AppStateStatus {
63+
return useSyncExternalStore(subscribe, getSnapshot);
64+
}

‎packages/react-native/ReactNativeApi.d.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<09cb4ee90e2e29b0924c0359d0ebef39>>
7+
* @generated SignedSource<<29785e09db994cf0d43e7426f47c1b30>>
88
*
99
* This file was generated by scripts/js-api/build-types/index.js.
1010
*/
@@ -5449,6 +5449,7 @@ declare function useAnimatedValueXY(
54495449
},
54505450
config?: Animated.AnimatedConfig | null | undefined,
54515451
): Animated.ValueXY
5452+
declare function useAppState(): AppStateStatus
54525453
declare function useColorScheme(): ColorSchemeName | null
54535454
declare function usePressability(
54545455
config: null | PressabilityConfig | undefined,
@@ -6084,6 +6085,7 @@ export {
60846085
useAnimatedColor, // 1faf88ce
60856086
useAnimatedValue, // dc9d6f5c
60866087
useAnimatedValueXY, // dc9057ea
6088+
useAppState, // 9338748a
60876089
useColorScheme, // d585efdb
60886090
usePressability, // af291ad3
60896091
useWindowDimensions, // bb4b683f

‎packages/react-native/index.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,9 @@ module.exports = {
383383
get useAnimatedColor() {
384384
return require('./Libraries/Animated/useAnimatedColor').default;
385385
},
386+
get useAppState() {
387+
return require('./Libraries/AppState/useAppState').default;
388+
},
386389
get useColorScheme() {
387390
return require('./Libraries/Utilities/useColorScheme').default;
388391
},

‎packages/react-native/index.js.flow‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ export type {
459459
EventHandlers as PressabilityEventHandlers,
460460
} from './Libraries/Pressability/Pressability';
461461
export {default as usePressability} from './Libraries/Pressability/usePressability';
462+
export {default as useAppState} from './Libraries/AppState/useAppState';
462463
export {default as useColorScheme} from './Libraries/Utilities/useColorScheme';
463464
export {default as useWindowDimensions} from './Libraries/Utilities/useWindowDimensions';
464465
export {default as UTFSequence} from './Libraries/UTFSequence';

0 commit comments

Comments
 (0)