Skip to content

Commit 01fc805

Browse files
authored
refactor(tabs): extract TabsContainerItem and memoize tab screens (#3827)
## Description Refactor `TabsContainer` to avoid unnecessary re-renders of all tab screens when only one tab's state changes. Currently, every tab screen is rendered inline inside `TabsContainer`, meaning any state change (e.g. tab selection) causes all screens to re-render. Extracting each screen into a memoized `TabsContainerItem` component allows React to skip re-rendering tabs whose props haven't changed. ## Changes - Extracted tab screen rendering logic (including safe area wrapping and context provision) into a new `TabsContainerItem` component wrapped with `React.memo`. - Consolidated `setRouteOptions` and `selectTab` callbacks into a `useTabsNavigationMethods` hook that returns a stable memoized object, so it can be passed to children without causing unnecessary re-renders. - Added `TabsNavigationMethods` type to `TabsContainer.types.tsx`. - Moved `getContent` and `getSafeAreaViewEdges` helper functions from `TabsContainer.tsx` to `TabsContainerItem.tsx` (co-located with their only consumer). ## Test plan - Verified tabs behavior in the FabricExample app — tab selection, safe area insets, and route options updates work as before. - Checked render logs (`RNSLog.info`) to confirm that non-selected tabs are no longer re-rendering on tab switch. ## Checklist - [x] Included code example that can be used to test this change. - [ ] Updated / created local changelog entries in relevant test files. - [ ] For visual changes, included screenshots / GIFs / recordings documenting the change. - [x] For API changes, updated relevant public types. - [ ] Ensured that CI passes
1 parent 84464ac commit 01fc805

File tree

5 files changed

+133
-86
lines changed

5 files changed

+133
-86
lines changed
Lines changed: 27 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import React from 'react';
2-
import { I18nManager, Platform, type NativeSyntheticEvent } from 'react-native';
2+
import { I18nManager, type NativeSyntheticEvent } from 'react-native';
33
import {
44
SCREEN_KEY_MORE_NAV_CTRL,
55
type TabSelectedEvent,
66
Tabs,
77
type TabsHostNavState,
88
} from 'react-native-screens';
9-
import { SafeAreaView, type SafeAreaViewProps } from 'react-native-screens/experimental'
109
import type {
1110
SelectTabMethod,
1211
TabRoute,
@@ -15,16 +14,14 @@ import type {
1514
TabsContainerProps,
1615
TabsContainerState,
1716
TabsNavigationAction,
17+
TabsNavigationMethods,
1818
} from './TabsContainer.types';
19-
import {
20-
TabsNavigationContext,
21-
type TabsNavigationContextPayload,
22-
} from './contexts/TabsNavigationContext';
2319
import {
2420
tabsNavigationReducerWithLogging,
2521
determineInitialTabsContainerState,
2622
} from './reducer';
2723
import { RNSLog } from 'react-native-screens/private';
24+
import { TabsContainerItem } from './TabsContainerItem';
2825

2926
export function TabsContainer(props: TabsContainerProps) {
3027
RNSLog.info('TabsContainer render');
@@ -48,13 +45,6 @@ export function TabsContainer(props: TabsContainerProps) {
4845
determineInitialTabsContainerState,
4946
);
5047

51-
const setRouteOptions = React.useCallback(
52-
(routeKey: string, options: Partial<TabRouteOptions>) => {
53-
dispatch({ type: 'set-options', routeKey, options });
54-
},
55-
[],
56-
);
57-
5848
const hostNavState = useTabsHostNavState(tabsNavState);
5949

6050
const onTabSelectedCallback = React.useCallback(
@@ -82,12 +72,7 @@ export function TabsContainer(props: TabsContainerProps) {
8272
[onTabSelected],
8373
);
8474

85-
const selectTabMethod: SelectTabMethod = React.useCallback(
86-
(routeKey: string) => {
87-
dispatch({ type: 'tab-select', routeKey });
88-
},
89-
[],
90-
);
75+
const navMethods = useTabsNavigationMethods(dispatch);
9176

9277
return (
9378
<Tabs.Host
@@ -105,77 +90,12 @@ export function TabsContainer(props: TabsContainerProps) {
10590
const pendingForUpdate =
10691
route.routeKey === tabsNavState.suggestedState.selectedRouteKey;
10792

108-
RNSLog.info(
109-
`TabsContainer map to component -> ${route.routeKey} ${isSelected ? '(selected)' : ''
110-
}`,
111-
);
112-
113-
const tabsNavigationContext: TabsNavigationContextPayload = {
114-
routeKey: route.routeKey,
115-
routeOptions: { ...route.options },
116-
setRouteOptions,
117-
selectTab: selectTabMethod,
118-
isSelected: isSelected,
119-
shouldRenderContents: isSelected || pendingForUpdate,
120-
};
121-
122-
const { safeAreaConfiguration, ...nativeOptions } = route.options ?? {};
123-
124-
return (
125-
<Tabs.Screen
126-
key={route.routeKey}
127-
{...nativeOptions}
128-
screenKey={route.routeKey}>
129-
<TabsNavigationContext value={tabsNavigationContext}>
130-
{getContent(route.Component, safeAreaConfiguration)}
131-
</TabsNavigationContext>
132-
</Tabs.Screen>
133-
);
93+
return <TabsContainerItem key={route.routeKey} route={route} navMethods={navMethods} isSelected={isSelected} pendingForUpdate={pendingForUpdate} />
13494
})}
13595
</Tabs.Host>
13696
);
13797
}
13898

139-
function getContent(
140-
Component: TabRouteConfig['Component'],
141-
safeAreaConfiguration: SafeAreaViewProps | undefined,
142-
) {
143-
const safeAreaConfigurationWithDefault = getSafeAreaViewEdges(
144-
safeAreaConfiguration?.edges,
145-
);
146-
147-
const anySAVEdgeSet = Object.values(safeAreaConfigurationWithDefault).some(
148-
edge => edge === true,
149-
);
150-
151-
if (anySAVEdgeSet) {
152-
return (
153-
<SafeAreaView {...safeAreaConfiguration}>
154-
<Component />
155-
</SafeAreaView>
156-
);
157-
}
158-
159-
return <Component />;
160-
}
161-
162-
function getSafeAreaViewEdges(
163-
edges?: SafeAreaViewProps['edges'],
164-
): NonNullable<SafeAreaViewProps['edges']> {
165-
let defaultEdges: SafeAreaViewProps['edges'];
166-
167-
switch (Platform.OS) {
168-
case 'android':
169-
defaultEdges = { bottom: true };
170-
break;
171-
default:
172-
defaultEdges = {};
173-
break;
174-
}
175-
176-
return { ...defaultEdges, ...edges };
177-
}
178-
17999
function useTabsHostNavState(
180100
tabsNavState: TabsContainerState,
181101
): TabsHostNavState {
@@ -211,3 +131,25 @@ function useSanitizeRouteConfigs(routeConfigs: TabRouteConfig[]) {
211131
throw new Error(`[Tabs] Tab name "${SCREEN_KEY_MORE_NAV_CTRL}" is reserved and can not be used`);
212132
}
213133
}
134+
135+
function useTabsNavigationMethods(dispatch: React.Dispatch<TabsNavigationAction>): TabsNavigationMethods {
136+
const setRouteOptions = React.useCallback(
137+
(routeKey: string, options: Partial<TabRouteOptions>) => {
138+
dispatch({ type: 'set-options', routeKey, options });
139+
},
140+
[dispatch],
141+
);
142+
143+
const selectTab: SelectTabMethod = React.useCallback(
144+
(routeKey: string) => {
145+
dispatch({ type: 'tab-select', routeKey });
146+
},
147+
[dispatch],
148+
);
149+
150+
151+
return React.useMemo(() => ({
152+
setRouteOptions,
153+
selectTab
154+
}), [setRouteOptions, selectTab]);
155+
}

apps/src/shared/gamma/containers/tabs/TabsContainer.types.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export type TabsContainerState = {
5050
suggestedState: TabsNavState;
5151
};
5252

53-
/// Navigation actions
53+
/// Navigation actions (reducer)
5454

5555
export type TabsNavigationActionSelectTab = {
5656
type: 'tab-select';
@@ -110,3 +110,10 @@ export type SetTabOptionsMethod = (
110110
) => void;
111111

112112
export type SelectTabMethod = (routeKey: string) => void;
113+
114+
/// Navigation methods (user facing)
115+
116+
export type TabsNavigationMethods = {
117+
setRouteOptions: SetTabOptionsMethod;
118+
selectTab: SelectTabMethod;
119+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React from 'react';
2+
import { Tabs } from "react-native-screens";
3+
import type { TabRouteConfig } from './TabsContainer.types';
4+
import type { TabsContainerItemProps } from './TabsContainerItem.types';
5+
import { SafeAreaView, type SafeAreaViewProps } from 'react-native-screens/experimental';
6+
import { Platform } from 'react-native';
7+
import { TabsNavigationContext } from './contexts/TabsNavigationContext';
8+
import { RNSLog } from 'react-native-screens/private';
9+
10+
export const TabsContainerItem = React.memo(TabsContainerItemImpl);
11+
12+
function TabsContainerItemImpl(props: TabsContainerItemProps) {
13+
14+
RNSLog.info(
15+
`TabsContainerItem render: ${props.route.routeKey} ${props.isSelected ? '(selected)' : ''
16+
} ${props.pendingForUpdate ? '(pending)' : ''}`,
17+
);
18+
19+
const tabsNavigationContext = React.useMemo(() => {
20+
return {
21+
routeKey: props.route.routeKey,
22+
routeOptions: { ...props.route.options },
23+
setRouteOptions: props.navMethods.setRouteOptions,
24+
selectTab: props.navMethods.selectTab,
25+
isSelected: props.isSelected,
26+
shouldRenderContents: props.isSelected || props.pendingForUpdate,
27+
}
28+
}, [props.route.routeKey, props.route.options, props.navMethods, props.isSelected, props.pendingForUpdate]);
29+
30+
const { safeAreaConfiguration, ...nativeOptions } = props.route.options ?? {};
31+
32+
const screenKey = props.route.routeKey;
33+
34+
return (
35+
<Tabs.Screen
36+
key={screenKey}
37+
{...nativeOptions}
38+
screenKey={screenKey}>
39+
<TabsNavigationContext value={tabsNavigationContext}>
40+
{getContent(props.route.Component, safeAreaConfiguration)}
41+
</TabsNavigationContext>
42+
</Tabs.Screen>
43+
);
44+
}
45+
46+
function getContent(
47+
Component: TabRouteConfig['Component'],
48+
safeAreaConfiguration: SafeAreaViewProps | undefined,
49+
) {
50+
const safeAreaConfigurationWithDefault = getSafeAreaViewEdges(
51+
safeAreaConfiguration?.edges,
52+
);
53+
54+
const anySAVEdgeSet = Object.values(safeAreaConfigurationWithDefault).some(
55+
edge => edge === true,
56+
);
57+
58+
if (anySAVEdgeSet) {
59+
return (
60+
<SafeAreaView {...safeAreaConfiguration}>
61+
<Component />
62+
</SafeAreaView>
63+
);
64+
}
65+
66+
return <Component />;
67+
}
68+
69+
function getSafeAreaViewEdges(
70+
edges?: SafeAreaViewProps['edges'],
71+
): NonNullable<SafeAreaViewProps['edges']> {
72+
let defaultEdges: SafeAreaViewProps['edges'];
73+
74+
switch (Platform.OS) {
75+
case 'android':
76+
defaultEdges = { bottom: true };
77+
break;
78+
default:
79+
defaultEdges = {};
80+
break;
81+
}
82+
83+
return { ...defaultEdges, ...edges };
84+
}
85+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { TabRoute, TabsNavigationMethods } from './TabsContainer.types';
2+
3+
export type TabsContainerItemProps = {
4+
route: TabRoute;
5+
navMethods: TabsNavigationMethods;
6+
isSelected: boolean;
7+
pendingForUpdate: boolean;
8+
}
9+

apps/src/shared/gamma/containers/tabs/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export type {
1111
TabsContainerState,
1212
} from './TabsContainer.types';
1313

14+
export type {
15+
TabsContainerItemProps
16+
} from './TabsContainerItem.types.ts'
17+
1418
export type { TabsNavigationContextPayload } from './contexts/TabsNavigationContext';
1519
export type { TabsHostConfigContextPayload } from './contexts/TabsHostConfigContext';
1620

0 commit comments

Comments
 (0)