React-navigation/bottom tabs — icons “focused” property issue console.log(focused); // true // false
Viktor Chukhlovnpm package @react-navigation/bottom-tabs

Task description: Create a bottom tab navigation with custom icons. Icons and Texts should change color property when tab is active.
My working solution:
import React from 'react';
import {TouchableOpacity} from 'react-native';
import {NavigationContainer} from '@react-navigation/native';
import {createBottomTabNavigator} from '@react-navigation/bottom-tabs';
import Screens from '../screens';
import SvgIcons from '../assets/icons/svg';
import styles from './styles';
const Tab = createBottomTabNavigator();
const BottomNavigation = () => {
const defaultScreenOptions = {
tabBarStyle: styles.tabBarStyles,
};
return (
<NavigationContainer>
<Tab.Navigator screenOptions={defaultScreenOptions}>
<Tab.Screen
name={Screens.Home.name}
component={Screens.Home.component}
options={{
// This solution is not working. This method is doing a lot of updates and return false in the end.
// tabBarIcon: ({ focused }) => (
// <SvgIcons.Home color={focused ? colors.blue : colors.black} />
// ),
tabBarButton: ({onPress, accessibilityState}) => {
return (
<TouchableOpacity onPress={onPress}>
<SvgIcons.Home color={accessibilityState.selected ? colors.blue : colors.black} />
<Text style={styles.label(accessibilityState.selected)}>{Screens.Home.name}</Text>
</TouchableOpacity>
);
},
}}
/>
<Tab.Screen
name={Screens.Feed.name}
component={Screens.Feed.component}
options={{
// This solution is not working. This method is doing a lot of updates and return false in the end.
// tabBarIcon: ({ focused }) => (
// <SvgIcons.Feed color={focused ? colors.blue : colors.black} />
// ),
tabBarButton: ({onPress, accessibilityState}) => {
return (
<TouchableOpacity onPress={onPress}>
<SvgIcons.Feed color={accessibilityState.selected ? colors.blue : colors.black} />
<Text style={styles.label(accessibilityState.selected)}>{Screens.Feed.name}</Text>
</TouchableOpacity>
);
},
}}
/>
</Tab.Navigator>
</NavigationContainer>
);
};
export default BottomNavigation;
Conclusions:
A callback from tabBarIcon property is doing a lot of updates and return false in the end.
tabBarIcon: ({ focused }) => (
<SvgIcons.Home color={focused ? colors.blue : colors.black} />
),
React-navigation/native is the evil a lots of people are using
Digging around package @react-navigation/bottom-tabs I was faced with some legacy code, which you will see below. thats very funny that support team is using so bad approach.
File path:
./node_modules/@react-navigation/core/src/useNavigationBuilder.tsx
On the line #275 you can see like some developer did next iterations without memoization:
1. const screens = routeConfigs.reduce
2. const routeNames = routeConfigs.map
3. const routeKeyList = routeNames.reduce
3.1 screens[curr].keys.map…join(:)
4. const routeParamList = routeNames.reduce
5. const routeGetIdList = routeNames.reduce
But further down in line #360 I see a code which is wrapped with useMemo.
const [initializedState, isFirstStateInitialization] = React.useMemo(() => {
It’s so fucking funny, that so big project do not following to React Development guide. There are more than one libraries which are using a legacy code. This dirty code is doing a different bugs in the navigation module which are blocking developers around whole world.
Dirty code:
import {
CommonActions,
DefaultRouterOptions,
NavigationAction,
NavigationState,
ParamListBase,
PartialState,
Route,
Router,
RouterConfigOptions,
RouterFactory,
} from '@react-navigation/routers';
import * as React from 'react';
import { isValidElementType } from 'react-is';
import Group from './Group';
import isArrayEqual from './isArrayEqual';
import isRecordEqual from './isRecordEqual';
import NavigationHelpersContext from './NavigationHelpersContext';
import NavigationRouteContext from './NavigationRouteContext';
import NavigationStateContext from './NavigationStateContext';
import Screen from './Screen';
import {
DefaultNavigatorOptions,
EventMapBase,
EventMapCore,
NavigatorScreenParams,
PrivateValueStore,
RouteConfig,
} from './types';
import useChildListeners from './useChildListeners';
import useComponent from './useComponent';
import useCurrentRender from './useCurrentRender';
import useDescriptors, { ScreenConfigWithParent } from './useDescriptors';
import useEventEmitter from './useEventEmitter';
import useFocusedListenersChildrenAdapter from './useFocusedListenersChildrenAdapter';
import useFocusEvents from './useFocusEvents';
import useKeyedChildListeners from './useKeyedChildListeners';
import useNavigationHelpers from './useNavigationHelpers';
import useOnAction from './useOnAction';
import useOnGetState from './useOnGetState';
import useOnRouteFocus from './useOnRouteFocus';
import useRegisterNavigator from './useRegisterNavigator';
import useScheduleUpdate from './useScheduleUpdate';
// This is to make TypeScript compiler happy
// eslint-disable-next-line babel/no-unused-expressions
PrivateValueStore;
type NavigatorRoute<State extends NavigationState> = {
key: string;
params?: NavigatorScreenParams<ParamListBase, State>;
};
const isValidKey = (key: unknown) =>
key === undefined || (typeof key === 'string' && key !== '');
/**
* Extract route config object from React children elements.
*
* @param children React Elements to extract the config from.
*/
const getRouteConfigsFromChildren = <
State extends NavigationState,
ScreenOptions extends {},
EventMap extends EventMapBase
>(
children: React.ReactNode,
groupKey?: string,
groupOptions?: ScreenConfigWithParent<
State,
ScreenOptions,
EventMap
>['options']
) => {
const configs = React.Children.toArray(children).reduce<
ScreenConfigWithParent<State, ScreenOptions, EventMap>[]
>((acc, child) => {
if (React.isValidElement(child)) {
if (child.type === Screen) {
// We can only extract the config from `Screen` elements
// If something else was rendered, it's probably a bug
if (!isValidKey(child.props.navigationKey)) {
throw new Error(
`Got an invalid 'navigationKey' prop (${JSON.stringify(
child.props.navigationKey
)}) for the screen '${
child.props.name
}'. It must be a non-empty string or 'undefined'.`
);
}
acc.push({
keys: [groupKey, child.props.navigationKey],
options: groupOptions,
props: child.props as RouteConfig<
ParamListBase,
string,
State,
ScreenOptions,
EventMap
>,
});
return acc;
}
if (child.type === React.Fragment || child.type === Group) {
if (!isValidKey(child.props.navigationKey)) {
throw new Error(
`Got an invalid 'navigationKey' prop (${JSON.stringify(
child.props.navigationKey
)}) for the group. It must be a non-empty string or 'undefined'.`
);
}
// When we encounter a fragment or group, we need to dive into its children to extract the configs
// This is handy to conditionally define a group of screens
acc.push(
...getRouteConfigsFromChildren<State, ScreenOptions, EventMap>(
child.props.children,
child.props.navigationKey,
child.type !== Group
? groupOptions
: groupOptions != null
? [...groupOptions, child.props.screenOptions]
: [child.props.screenOptions]
)
);
return acc;
}
}
throw new Error(
`A navigator can only contain 'Screen', 'Group' or 'React.Fragment' as its direct children (found ${
React.isValidElement(child)
? `'${
typeof child.type === 'string' ? child.type : child.type?.name
}'${
child.props?.name ? ` for the screen '${child.props.name}'` : ''
}`
: typeof child === 'object'
? JSON.stringify(child)
: `'${String(child)}'`
}). To render this component in the navigator, pass it in the 'component' prop to 'Screen'.`
);
}, []);
if (process.env.NODE_ENV !== 'production') {
configs.forEach((config) => {
const { name, children, component, getComponent } = config.props;
if (typeof name !== 'string' || !name) {
throw new Error(
`Got an invalid name (${JSON.stringify(
name
)}) for the screen. It must be a non-empty string.`
);
}
if (
children != null ||
component !== undefined ||
getComponent !== undefined
) {
if (children != null && component !== undefined) {
throw new Error(
`Got both 'component' and 'children' props for the screen '${name}'. You must pass only one of them.`
);
}
if (children != null && getComponent !== undefined) {
throw new Error(
`Got both 'getComponent' and 'children' props for the screen '${name}'. You must pass only one of them.`
);
}
if (component !== undefined && getComponent !== undefined) {
throw new Error(
`Got both 'component' and 'getComponent' props for the screen '${name}'. You must pass only one of them.`
);
}
if (children != null && typeof children !== 'function') {
throw new Error(
`Got an invalid value for 'children' prop for the screen '${name}'. It must be a function returning a React Element.`
);
}
if (component !== undefined && !isValidElementType(component)) {
throw new Error(
`Got an invalid value for 'component' prop for the screen '${name}'. It must be a valid React Component.`
);
}
if (getComponent !== undefined && typeof getComponent !== 'function') {
throw new Error(
`Got an invalid value for 'getComponent' prop for the screen '${name}'. It must be a function returning a React Component.`
);
}
if (typeof component === 'function') {
if (component.name === 'component') {
// Inline anonymous functions passed in the `component` prop will have the name of the prop
// It's relatively safe to assume that it's not a component since it should also have PascalCase name
// We won't catch all scenarios here, but this should catch a good chunk of incorrect use.
console.warn(
`Looks like you're passing an inline function for 'component' prop for the screen '${name}' (e.g. component={() => <SomeComponent />}). Passing an inline function will cause the component state to be lost on re-render and cause perf issues since it's re-created every render. You can pass the function as children to 'Screen' instead to achieve the desired behaviour.`
);
} else if (/^[a-z]/.test(component.name)) {
console.warn(
`Got a component with the name '${component.name}' for the screen '${name}'. React Components must start with an uppercase letter. If you're passing a regular function and not a component, pass it as children to 'Screen' instead. Otherwise capitalize your component's name.`
);
}
}
} else {
throw new Error(
`Couldn't find a 'component', 'getComponent' or 'children' prop for the screen '${name}'. This can happen if you passed 'undefined'. You likely forgot to export your component from the file it's defined in, or mixed up default import and named import when importing.`
);
}
});
}
return configs;
};
/**
* Hook for building navigators.
*
* @param createRouter Factory method which returns router object.
* @param options Options object containing `children` and additional options for the router.
* @returns An object containing `state`, `navigation`, `descriptors` objects.
*/
export default function useNavigationBuilder<
State extends NavigationState,
RouterOptions extends DefaultRouterOptions,
ActionHelpers extends Record<string, () => void>,
ScreenOptions extends {},
EventMap extends Record<string, any>
>(
createRouter: RouterFactory<State, any, RouterOptions>,
options: DefaultNavigatorOptions<
ParamListBase,
State,
ScreenOptions,
EventMap
> &
RouterOptions
) {
const navigatorKey = useRegisterNavigator();
const route = React.useContext(NavigationRouteContext) as
| NavigatorRoute<State>
| undefined;
const { children, screenListeners, ...rest } = options;
const { current: router } = React.useRef<Router<State, any>>(
createRouter({
...(rest as unknown as RouterOptions),
...(route?.params &&
route.params.state == null &&
route.params.initial !== false &&
typeof route.params.screen === 'string'
? { initialRouteName: route.params.screen }
: null),
})
);
const routeConfigs = getRouteConfigsFromChildren<
State,
ScreenOptions,
EventMap
>(children);
const screens = routeConfigs.reduce<
Record<string, ScreenConfigWithParent<State, ScreenOptions, EventMap>>
>((acc, config) => {
if (config.props.name in acc) {
throw new Error(
`A navigator cannot contain multiple 'Screen' components with the same name (found duplicate screen named '${config.props.name}')`
);
}
acc[config.props.name] = config;
return acc;
}, {});
const routeNames = routeConfigs.map((config) => config.props.name);
const routeKeyList = routeNames.reduce<Record<string, React.Key | undefined>>(
(acc, curr) => {
acc[curr] = screens[curr].keys.map((key) => key ?? '').join(':');
return acc;
},
{}
);
const routeParamList = routeNames.reduce<Record<string, object | undefined>>(
(acc, curr) => {
const { initialParams } = screens[curr].props;
acc[curr] = initialParams;
return acc;
},
{}
);
const routeGetIdList = routeNames.reduce<
RouterConfigOptions['routeGetIdList']
>(
(acc, curr) =>
Object.assign(acc, {
[curr]: screens[curr].props.getId,
}),
{}
);
if (!routeNames.length) {
throw new Error(
"Couldn't find any screens for the navigator. Have you defined any screens as its children?"
);
}
const isStateValid = React.useCallback(
(state) => state.type === undefined || state.type === router.type,
[router.type]
);
const isStateInitialized = React.useCallback(
(state) =>
state !== undefined && state.stale === false && isStateValid(state),
[isStateValid]
);
const {
state: currentState,
getState: getCurrentState,
setState: setCurrentState,
setKey,
getKey,
getIsInitial,
} = React.useContext(NavigationStateContext);
const stateCleanedUp = React.useRef(false);
const cleanUpState = React.useCallback(() => {
setCurrentState(undefined);
stateCleanedUp.current = true;
}, [setCurrentState]);
const setState = React.useCallback(
(state: NavigationState | PartialState<NavigationState> | undefined) => {
if (stateCleanedUp.current) {
// State might have been already cleaned up due to unmount
// We do not want to expose API allowing to override this
// This would lead to old data preservation on main navigator unmount
return;
}
setCurrentState(state);
},
[setCurrentState]
);
const [initializedState, isFirstStateInitialization] = React.useMemo(() => {
const initialRouteParamList = routeNames.reduce<
Record<string, object | undefined>
>((acc, curr) => {
const { initialParams } = screens[curr].props;
const initialParamsFromParams =
route?.params?.state == null &&
route?.params?.initial !== false &&
route?.params?.screen === curr
? route.params.params
: undefined;
acc[curr] =
initialParams !== undefined || initialParamsFromParams !== undefined
? {
...initialParams,
...initialParamsFromParams,
}
: undefined;
return acc;
}, {});
// If the current state isn't initialized on first render, we initialize it
// We also need to re-initialize it if the state passed from parent was changed (maybe due to reset)
// Otherwise assume that the state was provided as initial state
// So we need to rehydrate it to make it usable
if (
(currentState === undefined || !isStateValid(currentState)) &&
route?.params?.state == null
) {
return [
router.getInitialState({
routeNames,
routeParamList: initialRouteParamList,
routeGetIdList,
}),
true,
];
} else {
return [
router.getRehydratedState(
route?.params?.state ?? (currentState as PartialState<State>),
{
routeNames,
routeParamList: initialRouteParamList,
routeGetIdList,
}
),
false,
];
}
// We explicitly don't include routeNames, route.params etc. in the dep list
// below. We want to avoid forcing a new state to be calculated in those cases
// Instead, we handle changes to these in the nextState code below. Note
// that some changes to routeConfigs are explicitly ignored, such as changes
// to initialParams
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentState, router, isStateValid]);
const previousRouteKeyListRef = React.useRef(routeKeyList);
React.useEffect(() => {
previousRouteKeyListRef.current = routeKeyList;
});
const previousRouteKeyList = previousRouteKeyListRef.current;
let state =
// If the state isn't initialized, or stale, use the state we initialized instead
// The state won't update until there's a change needed in the state we have initalized locally
// So it'll be `undefined` or stale until the first navigation event happens
isStateInitialized(currentState)
? (currentState as State)
: (initializedState as State);
let nextState: State = state;
if (
!isArrayEqual(state.routeNames, routeNames) ||
!isRecordEqual(routeKeyList, previousRouteKeyList)
) {
// When the list of route names change, the router should handle it to remove invalid routes
nextState = router.getStateForRouteNamesChange(state, {
routeNames,
routeParamList,
routeGetIdList,
routeKeyChanges: Object.keys(routeKeyList).filter(
(name) =>
previousRouteKeyList.hasOwnProperty(name) &&
routeKeyList[name] !== previousRouteKeyList[name]
),
});
}
const previousNestedParamsRef = React.useRef(route?.params);
React.useEffect(() => {
previousNestedParamsRef.current = route?.params;
}, [route?.params]);
if (route?.params) {
const previousParams = previousNestedParamsRef.current;
let action: CommonActions.Action | undefined;
if (
typeof route.params.state === 'object' &&
route.params.state != null &&
route.params !== previousParams
) {
// If the route was updated with new state, we should reset to it
action = CommonActions.reset(route.params.state);
} else if (
typeof route.params.screen === 'string' &&
((route.params.initial === false && isFirstStateInitialization) ||
route.params !== previousParams)
) {
// If the route was updated with new screen name and/or params, we should navigate there
action = CommonActions.navigate({
name: route.params.screen,
params: route.params.params,
path: route.params.path,
});
}
// The update should be limited to current navigator only, so we call the router manually
const updatedState = action
? router.getStateForAction(nextState, action, {
routeNames,
routeParamList,
routeGetIdList,
})
: null;
nextState =
updatedState !== null
? router.getRehydratedState(updatedState, {
routeNames,
routeParamList,
routeGetIdList,
})
: nextState;
}
const shouldUpdate = state !== nextState;
useScheduleUpdate(() => {
if (shouldUpdate) {
// If the state needs to be updated, we'll schedule an update
setState(nextState);
}
});
// The up-to-date state will come in next render, but we don't need to wait for it
// We can't use the outdated state since the screens have changed, which will cause error due to mismatched config
// So we override the state object we return to use the latest state as soon as possible
state = nextState;
React.useEffect(() => {
setKey(navigatorKey);
if (!getIsInitial()) {
// If it's not initial render, we need to update the state
// This will make sure that our container gets notifier of state changes due to new mounts
// This is necessary for proper screen tracking, URL updates etc.
setState(nextState);
}
return () => {
// We need to clean up state for this navigator on unmount
// We do it in a timeout because we need to detect if another navigator mounted in the meantime
// For example, if another navigator has started rendering, we should skip cleanup
// Otherwise, our cleanup step will cleanup state for the other navigator and re-initialize it
setTimeout(() => {
if (getCurrentState() !== undefined && getKey() === navigatorKey) {
cleanUpState();
}
}, 0);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// We initialize this ref here to avoid a new getState getting initialized
// whenever initializedState changes. We want getState to have access to the
// latest initializedState, but don't need it to change when that happens
const initializedStateRef = React.useRef<State>();
initializedStateRef.current = initializedState;
const getState = React.useCallback((): State => {
const currentState = getCurrentState();
return isStateInitialized(currentState)
? (currentState as State)
: (initializedStateRef.current as State);
}, [getCurrentState, isStateInitialized]);
const emitter = useEventEmitter<EventMapCore<State>>((e) => {
let routeNames = [];
let route: Route<string> | undefined;
if (e.target) {
route = state.routes.find((route) => route.key === e.target);
if (route?.name) {
routeNames.push(route.name);
}
} else {
route = state.routes[state.index];
routeNames.push(
...Object.keys(screens).filter((name) => route?.name === name)
);
}
if (route == null) {
return;
}
const navigation = descriptors[route.key].navigation;
const listeners = ([] as (((e: any) => void) | undefined)[])
.concat(
// Get an array of listeners for all screens + common listeners on navigator
...[
screenListeners,
...routeNames.map((name) => {
const { listeners } = screens[name].props;
return listeners;
}),
].map((listeners) => {
const map =
typeof listeners === 'function'
? listeners({ route: route as any, navigation })
: listeners;
return map
? Object.keys(map)
.filter((type) => type === e.type)
.map((type) => map?.[type])
: undefined;
})
)
// We don't want same listener to be called multiple times for same event
// So we remove any duplicate functions from the array
.filter((cb, i, self) => cb && self.lastIndexOf(cb) === i);
listeners.forEach((listener) => listener?.(e));
});
useFocusEvents({ state, emitter });
React.useEffect(() => {
emitter.emit({ type: 'state', data: { state } });
}, [emitter, state]);
const { listeners: childListeners, addListener } = useChildListeners();
const { keyedListeners, addKeyedListener } = useKeyedChildListeners();
const onAction = useOnAction({
router,
getState,
setState,
key: route?.key,
actionListeners: childListeners.action,
beforeRemoveListeners: keyedListeners.beforeRemove,
routerConfigOptions: {
routeNames,
routeParamList,
routeGetIdList,
},
emitter,
});
const onRouteFocus = useOnRouteFocus({
router,
key: route?.key,
getState,
setState,
});
const navigation = useNavigationHelpers<
State,
ActionHelpers,
NavigationAction,
EventMap
>({
onAction,
getState,
emitter,
router,
});
useFocusedListenersChildrenAdapter({
navigation,
focusedListeners: childListeners.focus,
});
useOnGetState({
getState,
getStateListeners: keyedListeners.getState,
});
const descriptors = useDescriptors<
State,
ActionHelpers,
ScreenOptions,
EventMap
>({
state,
screens,
navigation,
screenOptions: options.screenOptions,
defaultScreenOptions: options.defaultScreenOptions,
onAction,
getState,
setState,
onRouteFocus,
addListener,
addKeyedListener,
router,
// @ts-expect-error: this should have both core and custom events, but too much work right now
emitter,
});
useCurrentRender({
state,
navigation,
descriptors,
});
const NavigationContent = useComponent(NavigationHelpersContext.Provider, {
value: navigation,
});
return {
state,
navigation,
descriptors,
NavigationContent,
};
}
Subscribe on my Telegram Channel where I am posting similar articles:
