How to Structure Navigation in React Native With Typescript

Leverage the typings — React Navigation

Tarik
Better Programming

--

Photo by Hendrik Morkel on Unsplash

Building navigation has been the fundamental feature of any mobile application these days. When it comes to React Native world, we have React Navigation in our pocket.

This article will go through how a typical React Navigation can be implemented using TypeScript meanwhile comparing different strategies of TypeScript usage.

And more importantly, it will also cover how we can make the most out of TypeScript features to be able to build a more scalable architecture on navigation.

Caveat: The examples in this article have been derived using @react-navigation@6.x version.

Caveat: The methods/strategies that will be covered in this article are not indispensable ones that you must strictly follow but they are just my predilections and found them useful to share with the readers of this article. React Navigation has already an amazing well-documented section regarding typescript usage. That being said, you can already build an awesome react-navigation typescript structure by just following the this documentation.

Creating Stack Navigator

Let‘s take a look at how to create a stack navigator using the React Navigation package.

See the example below taken from React Navigation;

import { createStackNavigator } from '@react-navigation/stack';

const Stack = createStackNavigator();

function MyStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={Home} />
<Stack.Screen name="Notifications" component={Notifications} />
<Stack.Screen name="Profile" component={Profile} />
<Stack.Screen name="Settings" component={Settings} />
</Stack.Navigator>
);
}

As can be seen that there is nothing specific about TypeScript here, let’s add a bit of TypeScript to this code snippet.

import { createStackNavigator } from '@react-navigation/stack';
import * as React from 'react';
import { View } from 'react-native';

type AppStackParamList = {
Home: undefined;
Notifications: undefined;
Profile: undefined;
Settings: undefined;
};

const Home = () => <View />;
const Notifications = () => <View />;
const Profile = () => <View />;
const Settings = () => <View />;

const Stack = createStackNavigator<AppStackParamList>();

function AppStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={Home} />
<Stack.Screen name="Notifications" component={Notifications} />
<Stack.Screen name="Profile" component={Profile} />
<Stack.Screen name="Settings" component={Settings} />
</Stack.Navigator>
);
}
@tarikfp

In the above, we have added the ParamList type for createStackNavigator. But hey, how do we know that createStackNavigator takes the generic type? There are a couple of ways to figure it out. It has always been good practice to review the documentation first. In this given example, you will already notice that there is already a section for it in the docs.

On the other hand, assuming that this specific typing of createStackNavigator is not documented on the website, then a possible way of determining whether the function has the typing would be just jumping to the type definition of the function. It can be intimidating at first since there will be lots of types, interfaces, generics flying around but the more you get into the habit of jumping to the type definition, it will be much less overwhelming.

Update!

If you wanna deal with extra complexity with typescript and also want to ignore brilliant TS utility from React team (React.ComponentProps), you can continue to read to the end of this article.

But I am afraid, this section might be the end!

We will be able to retrieve the typings for each stack screen and their configuration with React.ComponentProps<typeof YourStack.Screen> And the result will be simply amazing.

See below for complete example:

import { createStackNavigator } from "@react-navigation/stack";
import * as React from "react";
import { View } from "react-native";

type AppStackParamList = {
Home: undefined;
Notifications: undefined;
Profile: undefined;
Settings: undefined;
};

const Home = () => <View />;
const Notifications = () => <View />;
const Profile = () => <View />;
const Settings = () => <View />;

const Stack = createStackNavigator<AppStackParamList>();

const routes: Array<React.ComponentProps<typeof Stack.Screen>> = [
{
name: "Home",
component: Home,
},
{
name: "Notifications",
component: Notifications,
},
{
name: "Profile",
component: Profile,
},
{
name: "Settings",
component: Settings,
},
];

function AppStack() {
return (
<Stack.Navigator>
{routes.map((routeConfig) => (
<Stack.Screen key={routeConfig.name} {...routeConfig} />
))}
</Stack.Navigator>
);
}

And yes, it will still have the intellisense, it will still warn you if you put a non-related (or i.e: a screen that is not in your stack type declaration) screen to your stack, such that:

Invalid screen

Intellisense will be there as well:

It is also more clean, configurable, and dynamic:

  const getAuthStackRoutes = (
// other parameters...
theme: ThemeType
): Array<React.ComponentProps<typeof Stack.Screen>> => {
// more business logic...

return [
{
name: "Home",
component: Home,
options: {
headerStyle: {
backgroundColor: theme.colors.primary,
// other theme stylings...
},
},
},
];
};

In conclusion, this approach allows us to manage complex navigation structures easily(at least when we compare this specific approach with other approaches in this article). It is because while you will have cleaner JSX, you will be also able to configure your routesin a more dynamic, modular way.

Taking usage of TypeScript one step ahead

It is indeed not limited to declaring only generic type of createStackNavigator. How about the type of children elements that Stack.Navigator will have? Although this concept is already pretty well defined in the official documentation, let’s see below how we can play around with it to have a more decoupled way of creating the structure.

import type {
RouteConfig,
StackNavigationState,
} from '@react-navigation/core';
import {
createStackNavigator,
StackNavigationEventMap,
StackNavigationOptions,
} from '@react-navigation/stack';
import * as React from 'react';
import { View } from 'react-native';

// stack param list type
type AppStackParamList = {
Home: undefined;
Notifications: undefined;
Profile: undefined;
Settings: undefined;
};

// type of the single route in app stack
type AppStackRoutesType = RouteConfig<
AppStackParamList,
keyof AppStackParamList,
StackNavigationState<AppStackParamList>,
StackNavigationOptions,
StackNavigationEventMap
>;

// mocking the screen components for the sake of example
const Home = () => <View />;
const Notifications = () => <View />;
const Profile = () => <View />;
const Settings = () => <View />;

// strictly typed array of app stack routes
const appStackRoutes: Array<AppStackRoutesType> = [
{
name: 'Home',
component: Home,
},
{
name: 'Notifications',
component: Notifications,
},
{
name: 'Profile',
component: Profile,
},
{
name: 'Settings',
component: Settings,
},
];

const Stack = createStackNavigator<AppStackParamList>();

function AppStack() {
return (
<Stack.Navigator>
{/** mapping the app stack routes */}
{appStackRoutes.map((stackRoute) => (
<Stack.Screen key={stackRoute.name} {...stackRoute} />
))}
</Stack.Navigator>
);
}

Oops, there is so much going on here… Let’s divide each code block to separate parts.


import type {
RouteConfig,
StackNavigationState,
} from '@react-navigation/core';
import {
createStackNavigator,
StackNavigationEventMap,
StackNavigationOptions,
} from '@react-navigation/stack';
import * as React from 'react';
import { View } from 'react-native';

At the top of the code snippet, we are importing the necessary modules. These imports include types, functions, and other necessary stuff. They are not interesting but necessary :)

// mocking the screen components for the sake of example
const Home = () => <View />;
const Notifications = () => <View />;
const Profile = () => <View />;
const Settings = () => <View />;

Above, we are importing View component from react-native so that the example is demonstrable in a more simple way.

// type of the single route in app stack
type AppStackRoutesType = RouteConfig<
AppStackParamList,
keyof AppStackParamList,
StackNavigationState<AppStackParamList>,
StackNavigationOptions,
StackNavigationEventMap
>;

Now, here comes the interesting part. The RouteConfig which is imported from @react-navigation/core takes exactly five types as a generic type. And this particular type definition is not documented on the official react-navigation website, nonetheless, you can already figure this type definition out by just using the method of jumping to the type definition as we mentioned above.

Even though it might seem redundant/not necessary to have it on a typical react-native typescript project, it comes with several benefits which will be covered below section.

IntelliSense

Since we have a strictly typed stack routes(aka app routes) array, every element of the array is now obliged to stick with the type definition of the array. This means IntelliSense will work out of the box when adding a new element to the array.

Moreover, applying this type enforces us to update AppStackRoutesType whenever there will be a new element to be added to the app routes. Otherwise, we would not be able to add a new element that does not exist in the AppStackRoutesType(AppStackParamList).

Mapping the routes

Here comes the last part, we are creating the stack navigator using createStackNavigator. Then simply it is just a matter of mapping the array we created above along with the strict type of it.

We can eventually trust every element of our array, it will have correctly typed elements, thus it is safe to use spread syntax.

const Stack = createStackNavigator<AppStackParamList>();

function AppStack() {
return (
<Stack.Navigator>
{/** mapping the app stack routes */}
{appStackRoutes.map((stackRoute) => (
<Stack.Screen key={stackRoute.name} {...stackRoute} />
))}
</Stack.Navigator>
);
}

Was it really the last part?

Well… No!

We can still improve our typings on the example we’ve covered.

// generic stack routes type
export type StackRoutesType<ParamList extends ParamListBase> = Array<
RouteConfig<
ParamList,
keyof ParamList,
StackNavigationState<ParamList>,
StackNavigationOptions,
StackNavigationEventMap
>
>;

type AppStackRoutesType = StackRoutesType<AppStackParamList>;

// mocking the screen components for the sake of example
const Home = () => <View />;
const Notifications = () => <View />;
const Profile = () => <View />;
const Settings = () => <View />;

// strictly typed array of app stack routes
const appStackRoutes: AppStackRoutesType = [
{
name: 'Home',
component: Home,
},
{
name: 'Notifications',
component: Notifications,
},
{
name: 'Profile',
component: Profile,
},
{
name: 'Settings',
component: Settings,
},
];

The difference you might have noticed is that we have StackRoutesType which takes a generic type and equals the type that we have already seen before. But what is the benefit here you may ask. Better to answer this question in a visualized way.

// too much duplicated code...

type AppStackRoutesType = RouteConfig<
AppStackParamList,
keyof AppStackParamList,
StackNavigationState<AppStackParamList>,
StackNavigationOptions,
StackNavigationEventMap
>;

type OtherStackRoutesType = RouteConfig<
OtherStackParamList,
keyof OtherStackParamList,
StackNavigationState<OtherStackParamList>,
StackNavigationOptions,
StackNavigationEventMap
>;

type AnotherStackRoutesType = RouteConfig<
AnotherStackParamList,
keyof AnotherStackParamList,
StackNavigationState<AnotherStackParamList>,
StackNavigationOptions,
StackNavigationEventMap
>;

If we were not to choose to use generic StackRoutesType, we would have to duplicate typings for every stack we have in the app.

Therefore, this would be a nightmare as the app gets bigger. (here the notion is, number of stacks in the app and the complexity of the app are directly proportional, in fact, it is not a always 100% valid assumption, nevertheless it is suitable for this specific example)

// generic stack routes type
export type StackRoutesType<ParamList extends ParamListBase> = Array<
RouteConfig<
ParamList,
keyof ParamList,
StackNavigationState<ParamList>,
StackNavigationOptions,
StackNavigationEventMap
>
>;

// becomes one line typings for each stack
type AppStackRoutesType = StackRoutesType<AppStackParamList>;
type OtherStackRoutesType = StackRoutesType<OtherStackParamList>;
type AnotherStackRoutesType = StackRoutesType<AnotherStackParamList>;

On the other hand, see above how it becomes simpler and clean now when using generic type.

Latest pictures

Let’s compare how a typical Stack function would look in two different scenarios:

Picture 1: Rendering whole stack screens one by one in JSX.

// ...imports, components

function AppStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={Home} />
<Stack.Screen name="Notifications" component={Notifications} />
<Stack.Screen name="Profile" component={Profile} />
<Stack.Screen name="Settings" component={Settings} />
{/** n more screens... */}
</Stack.Navigator>
);
}

Picture 2: Rendering screens using the stack routes array created using the strictly typed typescript. 🎉

function AppStack() {
return (
<Stack.Navigator>
{appStackRoutes.map((stackRoute) => (
<Stack.Screen key={stackRoute.name} {...stackRoute} />
))}
</Stack.Navigator>
);
}

One more thing… What about Navigator props

We might have one more thing left to consider which is props of the stack navigator. Looking at the below StackNavigator, there exists a chunk of configurations for a single navigator. Why not have these configurations decoupled from JSX?

import type {
DefaultNavigatorOptions,
ParamListBase,
StackNavigationState,
} from '@react-navigation/core';
import {
StackNavigationEventMap,
StackNavigationOptions,
} from '@react-navigation/stack';
// other necessary imports for screens...

// generic typing for stack navigator options
type StackNavigatorOptions<ParamList extends ParamListBase> =
DefaultNavigatorOptions<
ParamList,
StackNavigationState<ParamList>,
StackNavigationOptions,
StackNavigationEventMap
>;

// omit the children as it corresponds to stack screen
const appStackNavigatorProps: Omit<
StackNavigatorOptions<AppStackParamList>,
'children'
> = {
initialRouteName: 'Home',
screenOptions: {
headerShown: false,
cardOverlayEnabled: true,
gestureEnabled: false,
cardStyle: {
backgroundColor: 'royalblue',
},
headerLeftContainerStyle: {
backgroundColor: 'firebrick',
},
presentation: 'modal',
headerTitleStyle: {
fontSize: 24,
color: 'olivedrab',
},
},
// ...rest
};

// screen components and stack routes...

function AppStack() {
return (
<Stack.Navigator {...appStackNavigatorProps}>
{appStackRoutes.map((stackRoute) => (
<Stack.Screen key={stackRoute.name} {...stackRoute} />
))}
</Stack.Navigator>
);
}

Starting from Line 13, we should be already familiar with why we use generic type. The bare difference in typing with the stack routes here might be the DefaultNavigatorOptions type, which is imported from @react-navigation/core. The name of the type itself is even self-explanatory, it is options for the navigator.

Similar to RouteConfig type we have mentioned above, it takes also multiple generic types. Besides this, bear in mind that we are explicitly omitting the children key as we are already rendering Stack.Navigator children which correspond to Stack.Screen.

On Line 22, we are creating a brand new object (or it can be a function, as long as it returns an object that satisfies the navigator options type) which will represent the options of our navigator.

As a result, on Line 50, this object can be safely used with spread syntax as long as it satisfies the given type.

Much cleaner JSX… IMO…

Latest pictures - Navigator props

Let’s again compare how a typical Stack function would look in two different scenarios:

Picture 1: Having stack navigator configurations in JSX.

function AppStack() {
return (
<Stack.Navigator
initialRouteName="Home"
screenOptions={{
headerShown: false,
cardOverlayEnabled: true,
gestureEnabled: false,
cardStyle: {
backgroundColor: 'royalblue',
},
headerLeftContainerStyle: {
backgroundColor: 'firebrick',
},
presentation: 'modal',
headerTitleStyle: {
fontSize: 24,
color: 'olivedrab',
},
}}
// rest...
>
{appStackRoutes.map((stackRoute) => (
<Stack.Screen key={stackRoute.name} {...stackRoute} />
))}
</Stack.Navigator>
);
}

Picture 2: Having stack navigator configurations decoupled from JSX.

// ...

function AppStack() {
return (
<Stack.Navigator {...appStackNavigatorProps}>
{appStackRoutes.map((stackRoute) => (
<Stack.Screen key={stackRoute.name} {...stackRoute} />
))}
</Stack.Navigator>
);
}

In this article, we have covered how we can actually leverage TypeScript and achieve a cleaner way to structure our react-native navigators.

Have a bug-free day everyone.

--

--