Skip to content

Navigation System

Mobile navigation requires handling:

  • Multiple navigation patterns (tabs, stacks, modals)
  • Deep linking from push notifications and external URLs
  • Authentication-based navigation guards
  • State preservation across navigation
  • Platform-specific navigation behaviors (iOS/Android)

Why React Navigation:

  • Native platform integration (iOS/Android navigation patterns)
  • Type-safe navigation with TypeScript
  • Deep linking and URL scheme support
  • Customizable transitions and animations
  • Extensive community support and documentation
AppNavigator (Root)
BottomSheetProvider → KeyboardProvider → RefsProvider
NavigationContainer (with deep linking)
Auth Check
├─ Logged In: Stack Navigator (ChatScreen, ContactDetails, Dashboard)
│ ↓
│ Tab Navigator
│ ├─ Inbox Tab → InboxStack
│ ├─ Conversations Tab → ConversationStack
│ └─ Settings Tab → SettingsStack
└─ Logged Out: AuthStack
├─ Login
├─ ConfigURL
└─ ForgotPassword

Tab Routes (src/navigation/tabs/AppTabs.tsx):

export type TabParamList = {
Conversations: undefined;
Inbox: undefined;
Settings: undefined;
Login: undefined;
ConfigInstallationURL: undefined;
ForgotPassword: undefined;
Search: undefined;
Notifications: undefined;
};

Stack Routes (Overlays):

export type TabBarExcludedScreenParamList = {
Tab: undefined;
ChatScreen: {
conversationId: number;
primaryActorId?: number;
primaryActorType?: string;
};
ContactDetails: { conversationId: number };
ConversationActions: undefined;
Dashboard: { url: string };
Login: undefined;
SearchScreen: undefined;
ImageScreen: undefined;
};

URL Structure:

{installationUrl}/app/accounts/{accountId}/conversations/{conversationId}/{primaryActorId?}/{primaryActorType?}

Configuration (src/navigation/index.tsx):

const linking = {
prefixes: [installationUrl], // From Redux state
config: {
screens: {
ChatScreen: {
path: 'app/accounts/:accountId/conversations/:conversationId/:primaryActorId?/:primaryActorType?',
parse: {
conversationId: (id: string) => parseInt(id),
primaryActorId: (id: string) => parseInt(id),
primaryActorType: (type: string) => decodeURIComponent(type),
},
},
},
},
getStateFromPath, // Custom path parsing
getInitialURL, // Handle deep links and push notifications
subscribe, // Listen for incoming links
};
App Start
Load Fonts & Assets
Check Auth State (Redux)
├─ Logged In
│ ↓
│ Initialize User Data
│ ↓
│ Setup WebSocket
│ ↓
│ Navigate to Tab Navigator (Inbox Tab)
└─ Logged Out
Navigate to AuthStack (Login)
Deep Link Received (URL or Push Notification)
Parse URL → Extract conversationId, accountId
Check Authentication
├─ Authenticated
│ ↓
│ Navigate to ChatScreen with params
│ ↓
│ Load Conversation Data
└─ Not Authenticated
Navigate to Login
Store Deep Link Intent
After Login → Navigate to Intended Screen
App Closed/Background → Notification Received
User Taps Notification
getInitialNotification() or onNotificationOpenedApp()
Extract FCM Message Data
Transform to Conversation Link
Navigate via Deep Link System

Main Navigation Container (src/navigation/index.tsx):

export const AppNavigationContainer = () => {
const [fontsLoaded] = useFonts({
'Inter-400-20': require('../assets/fonts/Inter-400-20.ttf'),
// ... other fonts
});
const installationUrl = useAppSelector(selectInstallationUrl);
const locale = useAppSelector(selectLocale);
const linking = {
prefixes: [installationUrl],
config: { /* ... */ },
getInitialURL: async () => {
// Check deep link
const url = await Linking.getInitialURL();
if (url) return url;
// Check push notification
const message = await messaging().getInitialNotification();
if (message) {
return extractConversationLink(message);
}
},
subscribe: (listener) => {
// Listen for deep links
const linkSubscription = Linking.addEventListener('url', ({ url }) =>
listener(url)
);
// Listen for push notifications
const notifSubscription = messaging().onNotificationOpenedApp(message => {
const link = extractConversationLink(message);
if (link) listener(link);
});
return () => {
linkSubscription.remove();
notifSubscription();
};
},
};
return (
<NavigationContainer
linking={linking}
ref={navigationRef}
fallback={<ActivityIndicator />}>
<AppTabs />
</NavigationContainer>
);
};

Conditional Rendering (src/navigation/tabs/AppTabs.tsx):

export const AppTabs = () => {
const isLoggedIn = useAppSelector(selectLoggedIn);
if (isLoggedIn) {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Tab" component={Tabs} />
<Stack.Screen
name="ChatScreen"
component={ChatScreen}
options={{ animation: 'slide_from_right' }}
/>
<Stack.Screen
name="ContactDetails"
component={ContactDetailsScreen}
options={{
presentation: 'formSheet',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen
name="Dashboard"
component={DashboardScreen}
options={{
presentation: 'formSheet',
animation: 'slide_from_bottom',
}}
/>
</Stack.Navigator>
);
} else {
return <AuthStack />;
}
};
const Tabs = () => {
const user = useAppSelector(selectUser);
const userPermissions = user ? getUserPermissions(user, user.account_id) : [];
const hasConversationPermission = CONVERSATION_PERMISSIONS.some(permission =>
userPermissions.includes(permission)
);
return (
<Tab.Navigator tabBar={CustomTabBar} initialRouteName="Inbox">
{hasConversationPermission && (
<Tab.Screen name="Inbox" component={InboxStack} />
)}
{hasConversationPermission && (
<Tab.Screen name="Conversations" component={ConversationStack} />
)}
<Tab.Screen name="Settings" component={SettingsStack} />
</Tab.Navigator>
);
};

Navigation Utilities (src/utils/navigationUtils.ts):

import { createNavigationContainerRef } from '@react-navigation/native';
export const navigationRef = createNavigationContainerRef();
export function navigate(name: string, params?: object) {
if (navigationRef.isReady()) {
navigationRef.navigate(name as never, params as never);
}
}
export function goBack() {
if (navigationRef.isReady() && navigationRef.canGoBack()) {
navigationRef.goBack();
}
}
export function getCurrentRoute() {
if (navigationRef.isReady()) {
return navigationRef.getCurrentRoute();
}
}

Usage in Components:

import { navigate } from '@/utils/navigationUtils';
const handleConversationPress = (conversationId: number) => {
navigate('ChatScreen', { conversationId });
};

Platform-Specific Animations:

<Stack.Screen
name="ChatScreen"
component={ChatScreen}
options={{
animation: 'slide_from_right', // iOS-style slide
headerShown: false,
}}
/>
<Stack.Screen
name="ContactDetails"
component={ContactDetailsScreen}
options={{
presentation: 'formSheet', // iOS modal presentation
animation: 'slide_from_bottom',
}}
/>

✅ Good Patterns:

// Use typed navigation
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
type NavigationProp = NativeStackNavigationProp<TabBarExcludedScreenParamList>;
const MyComponent = () => {
const navigation = useNavigation<NavigationProp>();
navigation.navigate('ChatScreen', { conversationId: 123 });
};
// Use navigation guards
useEffect(() => {
if (!isLoggedIn) {
navigation.replace('Login');
}
}, [isLoggedIn]);

❌ Anti-Patterns:

// DON'T: Use strings without type checking
navigation.navigate('NonExistentScreen'); // ❌ No compile-time error
// DON'T: Navigate before NavigationContainer is ready
// Always check navigationRef.isReady()
// DON'T: Store navigation state in Redux
// Let React Navigation manage its own state
describe('Navigation', () => {
it('should navigate to ChatScreen with conversationId', () => {
const { getByTestId } = render(<AppNavigator />);
fireEvent.press(getByTestId('conversation-item-1'));
expect(mockNavigation.navigate).toHaveBeenCalledWith(
'ChatScreen',
{ conversationId: 1 }
);
});
it('should redirect to login if not authenticated', () => {
mockUseAppSelector.mockReturnValue(false); // Not logged in
render(<AppNavigator />);
expect(screen.getByTestId('login-screen')).toBeTruthy();
});
});
describe('Deep Linking', () => {
it('should parse conversation URL correctly', async () => {
const url = 'https://app.Zelta Chat.com/app/accounts/1/conversations/123';
const state = linking.getStateFromPath(url, linking.config);
expect(state.routes[0]).toMatchObject({
name: 'ChatScreen',
params: { conversationId: 123 },
});
});
});

Screens are loaded on-demand:

const ChatScreen = lazy(() => import('@/screens/chat-screen/ChatScreen'));

Memoize screen options to prevent re-renders:

const screenOptions = useMemo(() => ({
headerShown: false,
animation: 'slide_from_right',
}), []);

React Navigation handles state persistence automatically. For custom persistence:

const [isReady, setIsReady] = useState(false);
const [initialState, setInitialState] = useState();
useEffect(() => {
const restoreState = async () => {
const savedState = await getNavigationState();
if (savedState) setInitialState(savedState);
setIsReady(true);
};
restoreState();
}, []);
<NavigationContainer
initialState={initialState}
onStateChange={saveNavigationState}
/>
  1. Define route params:
export type TabParamList = {
// ... existing routes
NewScreen: { param1: string; param2?: number };
};
  1. Add screen to navigator:
<Stack.Screen
name="NewScreen"
component={NewScreenComponent}
options={{
headerShown: true,
title: 'New Screen',
}}
/>
  1. Navigate to screen:
navigation.navigate('NewScreen', {
param1: 'value',
param2: 123
});
const linking = {
config: {
screens: {
// ... existing screens
NewScreen: {
path: 'new-feature/:id',
parse: {
id: (id: string) => parseInt(id),
},
},
},
},
};

Create custom tab bar component:

const CustomTabBar = (props: BottomTabBarProps) => {
return (
<View style={styles.tabBar}>
{props.state.routes.map((route, index) => (
<TouchableOpacity
key={route.key}
onPress={() => props.navigation.navigate(route.name)}>
<Text>{route.name}</Text>
</TouchableOpacity>
))}
</View>
);
};
<Tab.Navigator tabBar={CustomTabBar}>
{/* tabs */}
</Tab.Navigator>