diff --git a/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js b/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js
new file mode 100644
index 000000000000..0fac3d635a20
--- /dev/null
+++ b/packages/react-native/Libraries/Utilities/__tests__/useScreenDimensions-test.js
@@ -0,0 +1,131 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ */
+
+import type {ReactTestRenderer} from 'react-test-renderer';
+
+import Dimensions from '../Dimensions';
+import useScreenDimensions from '../useScreenDimensions';
+import {create, unmount} from '@react-native/jest-preset/jest/renderer';
+import * as React from 'react';
+import {act} from 'react-test-renderer';
+
+describe('useScreenDimensions', () => {
+ const window = {width: 320, height: 480, scale: 2, fontScale: 1};
+ const screen = {width: 400, height: 800, scale: 2, fontScale: 1};
+ let renderer: ?ReactTestRenderer;
+ let observed;
+ let renderCount;
+ let originalDimensions;
+
+ function TestComponent() {
+ observed = useScreenDimensions();
+ renderCount++;
+ return null;
+ }
+
+ beforeEach(() => {
+ originalDimensions = {
+ window: Dimensions.get('window'),
+ screen: Dimensions.get('screen'),
+ };
+ Dimensions.set({window, screen});
+ renderCount = 0;
+ });
+
+ afterEach(async () => {
+ if (renderer != null) {
+ await unmount(renderer);
+ renderer = null;
+ }
+ jest.restoreAllMocks();
+ Dimensions.set(originalDimensions);
+ });
+
+ it('returns screen metrics rather than window metrics', async () => {
+ renderer = await create();
+ expect(observed).toEqual(screen);
+ expect(observed).not.toEqual(window);
+ });
+
+ it.each([
+ ['width', 800],
+ ['height', 400],
+ ['scale', 3],
+ ['fontScale', 1.5],
+ ])('updates when %s changes', async (property, value) => {
+ renderer = await create();
+ const nextScreen = {...screen, [property]: value};
+ await act(() => {
+ Dimensions.set({window, screen: nextScreen});
+ });
+ expect(observed).toEqual(nextScreen);
+ });
+
+ it('does not render again for window-only changes or identical screen metrics', async () => {
+ renderer = await create();
+ const previousRenderCount = renderCount;
+ const previousDimensions = observed;
+ await act(() => {
+ Dimensions.set({window: {...window, width: 200}, screen: {...screen}});
+ });
+ expect(renderCount).toBe(previousRenderCount);
+ expect(observed).toBe(previousDimensions);
+ });
+
+ it('catches changes between render and subscription', async () => {
+ const nextScreen = {...screen, width: 800, height: 400};
+ function UpdateBeforeSubscription() {
+ React.useLayoutEffect(() => {
+ Dimensions.set({window, screen: nextScreen});
+ }, []);
+ return ;
+ }
+ renderer = await create();
+ expect(observed).toEqual(nextScreen);
+ });
+
+ it('removes its subscription on unmount', async () => {
+ const addEventListener = jest.spyOn(Dimensions, 'addEventListener');
+ renderer = await create();
+ const subscription = addEventListener.mock.results[0].value;
+ const remove = jest.spyOn(subscription, 'remove');
+ await unmount(renderer);
+ renderer = null;
+ expect(remove).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps its subscription across screen updates', async () => {
+ const addEventListener = jest.spyOn(Dimensions, 'addEventListener');
+ renderer = await create();
+ const subscription = addEventListener.mock.results[0].value;
+ const remove = jest.spyOn(subscription, 'remove');
+ for (const width of [600, 800]) {
+ await act(() => {
+ Dimensions.set({window, screen: {...screen, width}});
+ });
+ expect(observed).toEqual({...screen, width});
+ }
+ expect(addEventListener).toHaveBeenCalledTimes(1);
+ expect(remove).not.toHaveBeenCalled();
+ await unmount(renderer);
+ renderer = null;
+ expect(remove).toHaveBeenCalledTimes(1);
+ });
+
+ it('reads current metrics after changes while unmounted', async () => {
+ renderer = await create();
+ await unmount(renderer);
+ renderer = null;
+ const nextScreen = {...screen, width: 800};
+ Dimensions.set({window, screen: nextScreen});
+ renderer = await create();
+ expect(observed).toEqual(nextScreen);
+ });
+});
diff --git a/packages/react-native/Libraries/Utilities/useScreenDimensions.js b/packages/react-native/Libraries/Utilities/useScreenDimensions.js
new file mode 100644
index 000000000000..8073ecd99709
--- /dev/null
+++ b/packages/react-native/Libraries/Utilities/useScreenDimensions.js
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow strict-local
+ * @format
+ */
+
+import Dimensions from './Dimensions';
+import {
+ type DisplayMetrics,
+ type DisplayMetricsAndroid,
+} from './NativeDeviceInfo';
+import {useSyncExternalStore} from 'react';
+
+const subscribe = (onStoreChange: () => void) => {
+ const subscription = Dimensions.addEventListener('change', onStoreChange);
+ return () => subscription.remove();
+};
+
+let cachedScreen: DisplayMetrics | DisplayMetricsAndroid | void;
+
+function getSnapshot(): DisplayMetrics | DisplayMetricsAndroid {
+ const screen = Dimensions.get('screen');
+ // Dimensions can emit a new screen object without changing its metrics.
+ // Preserve snapshot identity so those events do not trigger a render.
+ if (
+ cachedScreen == null ||
+ cachedScreen.width !== screen.width ||
+ cachedScreen.height !== screen.height ||
+ cachedScreen.scale !== screen.scale ||
+ cachedScreen.fontScale !== screen.fontScale
+ ) {
+ cachedScreen = screen;
+ }
+ return cachedScreen;
+}
+
+/**
+ * React hook that provides the screen's width, height, scale, and
+ * font scale. Automatically updates when screen size or font scale changes.
+ *
+ * Width and height are reported in logical pixels, as in `Dimensions.get('screen')`.
+ */
+export default function useScreenDimensions():
+ DisplayMetrics | DisplayMetricsAndroid {
+ return useSyncExternalStore(subscribe, getSnapshot);
+}
diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts
index a549b879b33d..eeb7a0f34991 100644
--- a/packages/react-native/ReactNativeApi.d.ts
+++ b/packages/react-native/ReactNativeApi.d.ts
@@ -5433,6 +5433,7 @@ declare function useColorScheme(): ColorSchemeName | null
declare function usePressability(
config: null | PressabilityConfig | undefined,
): null | PressabilityEventHandlers
+declare function useScreenDimensions(): DisplayMetrics | DisplayMetricsAndroid
declare function useWindowDimensions(): DisplayMetrics | DisplayMetricsAndroid
declare type UTFSequence = typeof UTFSequence
declare type Value = null | {
@@ -6065,5 +6066,6 @@ export {
useAnimatedValueXY, // dc9057ea
useColorScheme, // d585efdb
usePressability, // af291ad3
+ useScreenDimensions, // a8bd8ad8
useWindowDimensions, // bb4b683f
}
diff --git a/packages/react-native/__typetests__/index.tsx b/packages/react-native/__typetests__/index.tsx
index 54ea8b7694ba..1708da7a3a0c 100644
--- a/packages/react-native/__typetests__/index.tsx
+++ b/packages/react-native/__typetests__/index.tsx
@@ -121,6 +121,7 @@ import {
requireNativeComponent,
useColorScheme,
useWindowDimensions,
+ useScreenDimensions,
// @ts-ignore
SectionListData,
ToastAndroid,
@@ -165,6 +166,11 @@ function testDimensions() {
subscription.remove();
}
+function TestUseScreenDimensions() {
+ const {width, height, scale, fontScale} = useScreenDimensions();
+ const metrics: number[] = [width, height, scale, fontScale];
+}
+
function TextUseWindowDimensions() {
const {width, height, scale, fontScale} = useWindowDimensions();
}
diff --git a/packages/react-native/index.js b/packages/react-native/index.js
index 79b601abb36d..4c4f198db168 100644
--- a/packages/react-native/index.js
+++ b/packages/react-native/index.js
@@ -389,6 +389,9 @@ module.exports = {
get usePressability() {
return require('./Libraries/Pressability/usePressability').default;
},
+ get useScreenDimensions() {
+ return require('./Libraries/Utilities/useScreenDimensions').default;
+ },
get useWindowDimensions() {
return require('./Libraries/Utilities/useWindowDimensions').default;
},
diff --git a/packages/react-native/index.js.flow b/packages/react-native/index.js.flow
index 6214d033bc16..24e98abc05ce 100644
--- a/packages/react-native/index.js.flow
+++ b/packages/react-native/index.js.flow
@@ -460,6 +460,7 @@ export type {
} from './Libraries/Pressability/Pressability';
export {default as usePressability} from './Libraries/Pressability/usePressability';
export {default as useColorScheme} from './Libraries/Utilities/useColorScheme';
+export {default as useScreenDimensions} from './Libraries/Utilities/useScreenDimensions';
export {default as useWindowDimensions} from './Libraries/Utilities/useWindowDimensions';
export {default as UTFSequence} from './Libraries/UTFSequence';
export {default as Vibration} from './Libraries/Vibration/Vibration';
diff --git a/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts b/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts
index cca9d8189f53..47c64cfa8f00 100644
--- a/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts
+++ b/packages/react-native/types_DEPRECATED/Libraries/Utilities/Dimensions.d.ts
@@ -76,4 +76,7 @@ export interface Dimensions {
export function useWindowDimensions(): ScaledSize;
+/** Returns screen dimensions and updates when they change. */
+export function useScreenDimensions(): ScaledSize;
+
export const Dimensions: Dimensions;