Proper React Navigation v5 with TypeScript
TL;DR: Use a Routes file that will contain all the screen names and a ParamList interface to be used in the StackNavigationProp and RouteProp interfaces
So I had this sitting in my drafts for quite some time now waiting to find some time to add the last bit for nested navigators, but I decided I will do that on another post and publish this in the meantime. This is a guide on how I use React Navigation (v5) with TypeScript.
Let’s say we have a React Native app written in TypeScript. This app uses React Navigation to handle navigating from one screen to the other.
Let’s say we have a HomeScreen
, a ProfileScreen
, and a SettingsScreen
.
If the project is written in plain JavaScript, this is what that would look like: 👇
// ... imports HomeScreen, ProfileScreen, SettingsScreen
const mainStack = createStackNavigator()
function MainNavigator() {
return (
<mainStack.Navigator>
<mainStack.Screen name="Home" component={HomeScreen} />
<mainStack.Screen name="Profile" component={ProfileScreen} />
<mainStack.Screen name="Settings" component={SettingsScreen} />
</mainStack.Navigator>
)
}
Then this is what navigating from HomeScreen
to the ProfileScreen
looks like:👇
// HomeScreen.jsx
function HomeScreen(props) {
const doSomething = () => {
if (props.route.params.someNavigationParam) {
props.navigation.navigate("Profile")
}
}
return <View style={styles.container} />
}
Pretty straightforward, a navigator with a bunch of screens with a name and the component for each screen, and then each screen gets the navigation
from the props
and calls navigation.navigate
with the screen name.
What we can’t know: Screen Usage
Now let’s say we need to see all the places from where our app is able to navigate to the Profile
screen. The way it is currently, we need to do some hacky text search throughout our codebase, and to make sure we don’t miss any place we need to do multiple searches like for example:
- for
navigate("Profile
- for
name: "Profile
so that we get results fornavigate({ name: "Profile"})
orreset({index: 0, routes: [{name: "Profile"}]})
We can change that very easily by having a map that holds all the screen names like this:👇
// routes.js
const routes = {
Home: "Home",
Profile: "Profile",
Settings: "Settings"
}
// then we reflect it in the navigator: 👇
<mainStack.Navigator>
<mainStack.Screen name={routes.Home} component={HomeScreen} /> <mainStack.Screen name={routes.Profile} component={ProfileScreen} /> <mainStack.Screen name={routes.Settings} component={SettingsScreen} /></mainStack.Navigator>
// and then anywhere we do navigation: 👇
navigation.navigate(routes.Profile)navigation.navigate({ name: routes.Profile })navigation.reset({index: 0, routes: [{ name: routes.Profile }]})
Now all we need to do is to find where the routes.Profile
is being used and we’ll get all the places in one nice search. This functionality is provided by our IDEs. In my case if I cmd+click on the routes.Profile
in Webstorm, it will immediately show me all its usages.
What we can’t know: Screen Params
Let’s say the Profile
screen depends on a username
that it uses to fetch all the details of the user’s profile, and it’s expecting to get that username
from props.route.params.username
.
Now, whenever we’re writing code somewhere else that will navigate the user to the Profile
screen, we need to remember that this screen requires a username
param like this: navigation.navigate(routes.Profile, {username: "whatever"})
. Imagine if the app was big with lots of screens, each expecting a different set of params, it would get really hard to work on the navigation and always remember what we need to pass to each screen.
We will take care of this in a bit, using TypeScript.
What we can’t know: The wrong Screen in the Navigator
Now imagine our app had another stack navigator called AuthNavigator
which has the LoginScreen
, SignupScreen
and the ForgotPasswordScreen
.
No one stops you from using one of those screens inside the MainNavigator
by mistake: 👇
<mainStack.Navigator>
<mainStack.Screen name={routes.Home} component={HomeScreen} />
<mainStack.Screen name={routes.Profile} component={ProfileScreen} />
<mainStack.Screen name={routes.Settings} component={SettingsScreen} />
<mainStack.Screen name={routes.Login} component={LoginScreen} /></mainStack.Navigator>
Let’s change that.
React Navigation with TypeScript
First of all let’s convert the routes map into an enum. Enums are much better to use for structures that only hold information like our routes
map because you can use them as a type as well.
// change this: 👇
const routes = {
Home: "Home",
Profile: "Profile",
Settings: "Settings",
Login: "Login",
Signup: "Signup",
ForgotPassword: "ForgotPassword"
}
// into this: 👇
enum Routes {
Home: "Home",
Profile: "Profile",
Settings: "Settings",
Login: "Login",
Signup: "Signup",
ForgotPassword: "ForgotPassword"
}
Types on the navigator’s side
If we check the documentation here, it shows that the createStackNavigator
function accepts a type argument that describes an interface where the keys are the screen names and the “values” are the params each screen requires. So let’s create one for our screens in MainNavigator
and AuthNavigator
: 👇
interface MainNavigatorParamsList {
[Routes.Home]: undefined // HomeScreen doesn't expect any navigation params
[Routes.Profile]: { username: string } // ProfileScreen expects a username param
[Routes.Settings]: undefined // SettingsScreen doesn't expect any navigation params
}
interface AuthNavigatorParamsList {
[Routes.Login]: undefined // LoginScreen doesn't expect any navigation params
[Routes.Signup]: undefined // SignupScreen doesn't expect any navigation params
[Routes.ForgotPassword]: { email?: string } // ForgotPasswordScreen expects an email optional param
}
And let’s pass them to the corresponding createStackNavigator
s: 👇
const mainStack = createStackNavigator<MainNavigatorParamsList>()function MainNavigator() {
return (
<mainStack.Navigator>
<mainStack.Screen name={Routes.Home} component={HomeScreen} />
<mainStack.Screen name={Routes.Profile} component={ProfileScreen} />
<mainStack.Screen name={Routes.Settings} component={SettingsScreen} />
</mainStack.Navigator>
)
}
const authStack = createStackNavigator<AuthNavigatorParamsList>()function AuthNavigator() {
return (
<authStack.Navigator>
<authStack.Screen name={Routes.Login} component={LoginScreen} />
<authStack.Screen name={Routes.Signup} component={SignupScreen} />
<authStack.Screen name={Routes.ForgotPassword} component={ForgotPasswordScreen} />
</authStack.Navigator>
)
}
Now if you try for example to add the LoginScreen
inside the MainNavigator
it will throw an error:
const mainStack = createStackNavigator<MainNavigatorParamsList>()
function MainNavigator() {
return (
<mainStack.Navigator>
<mainStack.Screen name={Routes.Home} component={HomeScreen} />
<mainStack.Screen name={Routes.Profile} component={ProfileScreen} />
<mainStack.Screen name={Routes.Settings} component={SettingsScreen} />
{/** This will throw an error: 👇 */} {/** ⚠️ Type 'Routes.Login' is not assignable to type 'Routes.Home | Routes.Profile | Routes.Settings' */} <authStack.Screen name={Routes.Login} component={LoginScreen} /> </mainStack.Navigator>
)
}
Types on the screens’ side
Again, looking at the React Navigation documentation here, we see that @react-navigation/stack
exposes a type called StackNavigationProp
for us to use when typing the navigation
prop wherever it comes from (either the screen’s props or the useNavigation
hook).
This type accepts two parameters.
- The first one is the interface that describes the screens and their params - basically the
ParamsLIst
interfaces we created previously (MainNavigatorParamsList
andAuthNavigatorParamsList
) - The second one is a string matching the keys in the interface passed as the first argument. So if the first argument we passed was the
MainNavigatorParamsList
then the second type argument can only be one of"Home"
,"Profile"
, or"Settings"
.
If we only use the first type argument then what we get is a type that describes the whole navigation possibilities of that navigator. If we use the second type argument, then we get a Navigation type that describes the navigation possibilities of the navigation
prop inside the screen that we passed as this second type argument.
For example, StackNavigationProp<MainNavigatorParamsList>
describes the navigation of the whole MainNavigator
and StackNavigationProp<MainNavigatorParamsList, Routes.Profile>
describes the navigation inside the ProfileScreen
.
Because that gets too long and hard to read let’s wrap it on our own type with generics:
import { StackNavigationProp } from '@react-navigation/stack';
// import MainNavigatorParamsList and AuthNavigatorParamsList
type MainNavigationProp<
RouteName extends keyof MainNavigatorParamsList = string
> = StackNavigationProp<MainNavigatorParamsList, RouteName>
type AuthNavigationProp<
RouteName extends keyof AuthNavigatorParamsList = string
> = StackNavigationProp<AuthNavigatorParamsList, RouteName>
If we use that, then our screens would look like this: 👇
// import Routes, MainNavigationProp and AuthNavigationProp
interface HomeScreenProps {
navigation: MainNavigationProp<Routes.Home>}
interface ProfileScreenProps {
navigation: MainNavigationProp<Routes.Profile>}
interface SettingsScreenProps {
navigation: MainNavigationProp<Routes.Settings>}
interface LoginScreenProps {
navigation: AuthNavigationProp<Routes.Login>}
interface SignupScreenProps {
navigation: AuthNavigationProp<Routes.Signup>}
interface ForgotPasswordScreenProps {
navigation: AuthNavigationProp<Routes.ForgotPassword>}
function HomeScreen(props: HomeScreenProps) {/***/}function ProfileScreen(props: ProfileScreenProps) {/***/}function SettingsScreen(props: SettingsScreenProps) {/***/}function LoginScreen(props: LoginScreenProps) {/***/}function SignupScreen(props: SignupScreenProps) {/***/}function ForgotPasswordScreen(props: ForgotPasswordScreenProps) {/***/}
And if we’re using the useNavigation
hook :
// import Routes, MainNavigationProp and AuthNavigationProp
function HomeScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Home>>() // ...
}
function ProfileScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Profile>>() // ...
}
function SettingsScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Settings>>() // ...
}
function LoginScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.Login>>() // ...
}
function SignupScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.Signup>>() // ...
}
function ForgotPasswordScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.ForgotPassword>>() // ...
}
Now if we are about to navigate from the HomeScreen
to the ProfileScreen
without passing the username param, TypeScript gives us an error.
// TS throws an error that we're not passing the username param
navigation.navigate(Routes.Profile)
Accessing the route params
Now that’s all good and nice but we still have one problem. The ProfileScreen
is supposed to expect a username
in the route params but if we try to access it, TypeScript gives us one more error:
interface ProfileScreenProps {
navigation: MainNavigation<Routes.Profile>
}
function ProfileScreen(props: ProfileScreenProps) {
const navigation = useNavigation<MainNavigation<Routes.Profile>>()
// ⚠️ undefined is not an object (evaluating props.route.params) const username = props.route.params.username // ...
}
This is because we haven’t yet included the route
prop into the ProfileScreenProps
. Again, react-navigation’s documentation shows us that @react-navigation/native
exposes a type called RouteProp
.
Similarly to the StackNavigationProp
, it accepts two type arguments, the first being the navigator’s ParamsList
and the second being a string of the current screen.
Again, because this will get long let’s create our own wrapping type:
import { RouteProp } from '@react-navigation/native`';
// import MainNavigatorParamsList and AuthNavigatorParamsList
type MainRouteProp<
RouteName extends keyof MainNavigatorParamsList = string
> = RouteProp<MainNavigatorParamsList, RouteName>
type AuthRouteProp<
RouteName extends keyof AuthNavigatorParamsList = string
> = RouteProp<AuthNavigatorParamsList, RouteName>
If we use that, then our screens would look like this: 👇
// import Routes, MainNavigationProp, MainRouteProp, and AuthNavigationProp, AuthRouteProp
interface HomeScreenProps {
navigation: MainNavigationProp<Routes.Home>
route: MainRouteProp<Routes.Home>}
interface ProfileScreenProps {
navigation: MainNavigationProp<Routes.Profile>
route: MainRouteProp<Routes.Profile>}
interface SettingsScreenProps {
navigation: MainNavigationProp<Routes.Settings>
route: MainRouteProp<Routes.Settings>}
interface LoginScreenProps {
navigation: AuthNavigationProp<Routes.Login>
route: AuthRouteProp<Routes.Login>}
interface SignupScreenProps {
navigation: AuthNavigationProp<Routes.Signup>
route: AuthRouteProp<Routes.Signup>}
interface ForgotPasswordScreenProps {
navigation: AuthNavigationProp<Routes.ForgotPassword>
route: AuthRouteProp<Routes.ForgotPassword>}
function HomeScreen(props: HomeScreenProps) {/***/}
function ProfileScreen(props: ProfileScreenProps) {
const username = props.route.params.username}
function SettingsScreen(props: SettingsScreenProps) {/***/}
function LoginScreen(props: LoginScreenProps) {/***/}
function SignupScreen(props: SignupScreenProps) {/***/}
function ForgotPasswordScreen(props: ForgotPasswordScreenProps) {
const email = props.route.params.email}
And if we’re using the useRoute
hook:
// import Routes, MainNavigationProp, MainRouteProp, and AuthNavigationProp, AuthRouteProp
function HomeScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Home>>()
const route = useRoute<MainRouteProp<Routes.Home>>() // ...
}
function ProfileScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Profile>>()
const route = useRoute<MainRouteProp<Routes.Profile>>() // ...
}
function SettingsScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Settings>>()
const route = useRoute<MainRouteProp<Routes.Settings>>() // ...
}
function LoginScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.Login>>()
const route = useRoute<AuthRouteProp<Routes.Login>>() // ...
}
function SignupScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.Signup>>() const route = useRoute<AuthRouteProp<Routes.Signup>>()
// ...
}
function ForgotPasswordScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.ForgotPassword>>()
const route = useRoute<AuthRouteProp<Routes.ForgotPassword>>() // ...
}
Now if we try again to access the username
param from within the ProfileScreen
we will get no errors and we can even have autocomplete functionality from our IDE.
interface ProfileScreenProps {
navigation: MainNavigation<Routes.Profile>
route: MainRouteProp<Routes.Profile>}
function ProfileScreen(props: ProfileScreenProps) {
const navigation = useNavigation<MainNavigation<Routes.Profile>>()
const username = props.route.params.username // ✅ works fine // ...
}
A good practice for minimal cross-file changes
So now we have everything set up and ready, however, it’s not the most optimal for further developer and maintenance. For example, if we need to make a change so that the ProfileScreen
now requires more params like id
, firstName
, lastName
, avatar
and birthday
, we need to make the change on another file - where the navigator is. This isn’t the best approach in my opinion, because if you’re working on the ProfileScreen
and you decide that a new param is required, you need to switch to the other file, and then get back to the one where the Profile screen is declared.
To tackle that, I like to export an interface for each screen’s params directly from within that screen and then use that in the navigator’s file. So that way our total project structure would be like this: 👇
// routes.ts
export enum Routes {
Home: "Home",
Profile: "Profile",
Settings: "Settings",
Login: "Login",
Signup: "Signup",
ForgotPassword: "ForgotPassword"
}
// navigators.tsx
import {Routes} from "./routes"
import HomeScreen, {HomeScreenParams} from "../screens/HomeScreen"import ProfileScreen, {ProfileScreenParams} from "../screens/ProfileScreen"import SettingsScreen, {SettingsScreenParams} from "../screens/SettingsScreen"import LoginScreen, {LoginScreenParams} from "../screens/LoginScreen"import SignupScreen, {SignupScreenParams} from "../screens/SignupScreen"import ForgotPasswordScreen, {ForgotPasswordScreenParams} from "../screens/ForgotPasswordScreen"
interface MainNavigatorParamsList {
[Routes.Home]: HomeScreenParams [Routes.Profile]: ProfileScreenParams [Routes.Settings]: SettingsScreenParams}
export type MainNavigationProp<
RouteName extends keyof MainNavigatorParamsList = string
> = StackNavigationProp<MainNavigatorParamsList, RouteName>
const mainStack = createStackNavigator<MainNavigatorParamsList>()
function MainNavigator() {
return (
<mainStack.Navigator>
<mainStack.Screen name={Routes.Home} component={HomeScreen} />
<mainStack.Screen name={Routes.Profile} component={ProfileScreen} />
<mainStack.Screen name={Routes.Settings} component={SettingsScreen} />
</mainStack.Navigator>
)
}
interface AuthNavigatorParamsList {
[Routes.Login]: LoginScreenParams [Routes.Signup]: SignupScreenParams [Routes.ForgotPassword]: ForgotPasswordScreenParams}
export type AuthNavigationProp<
RouteName extends keyof AuthNavigatorParamsList = string
> = StackNavigationProp<AuthNavigatorParamsList, RouteName>
const authStack = createStackNavigator<AuthNavigatorParamsList>()
function AuthNavigator() {
return (
<authStack.Navigator>
<authStack.Screen name={Routes.Login} component={LoginScreen} />
<authStack.Screen name={Routes.Signup} component={SignupScreen} />
<authStack.Screen name={Routes.ForgotPassword} component={ForgotPasswordScreen} />
</authStack.Navigator>
)
}
// screens/HomeScreen.tsx
import {Routes} from '../navigation/routes'
import type {MainNavigationProp, MainRouteProp}
export type HomeScreenParams = undefinedexport default function HomeScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Home>>()
const route = useRoute<MainRouteProp<Routes.Home>>()
// ...
}
// screens/ProfileScreen.tsx
import {Routes} from '../navigation/routes'
import type {MainNavigationProp, MainRouteProp}
export interface ProfileScreenParams { id: string username: string fristName: string lastName: string avatar: string birthday?: number}export default function ProfileScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Profile>>()
const route = useRoute<MainRouteProp<Routes.Profile>>()
// ...
}
// screens/SettingsScreen.tsx
import {Routes} from '../navigation/routes'
import type {MainNavigationProp, MainRouteProp}
export type SettingsScreenParams = undefinedexport default function SettingsScreen() {
const navigation = useNavigation<MainNavigationProp<Routes.Settings>>()
const route = useRoute<MainRouteProp<Routes.Settings>>()
// ...
}
// screens/LoginScreen.tsx
import {Routes} from '../navigation/routes'
import type {AuthNavigationProp, AuthRouteProp}
export type LoginScreenParams = undefinedexport default function LoginScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.Login>>()
const route = useRoute<AuthRouteProp<Routes.Login>>()
// ...
}
// screens/SignupScreen.tsx
import {Routes} from '../navigation/routes'
import type {AuthNavigationProp, AuthRouteProp}
export type SignupScreenParams = undefinedexport default function SignupScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.Signup>>()
const route = useRoute<AuthRouteProp<Routes.Signup>>()
// ...
}
// screens/ForgotPasswordScreen.tsx
import {Routes} from '../navigation/routes'
import type {AuthNavigationProp, AuthRouteProp}
export interface ForgotPasswordScreenParams { email?: string}export default function ForgotPasswordScreen() {
const navigation = useNavigation<AuthNavigationProp<Routes.ForgotPassword>>()
const route = useRoute<AuthRouteProp<Routes.ForgotPassword>>()
// ...
}
Now, whenever we need to add a new param requirement on a screen, all we need to do is update that screen’s Param interface. Then because we now have proper IDE navigation we can search for all usages of the screen at hand, and very fast reach all the places where navigation actions would need updating to include the new param changes. What’s also great here, is that now the navigators file doesn’t have any say in how each screen’s params look like, but still has knowledge of them.
Coming up
In the next post, I will share details on how the above approach can be done when we have nested navigators where a screen inside a nested navigation can fire navigation events to be handled by a sibling navigator/screen of the parent navigator. (eg: RootNavigation with LaunchScreen, MainNavigator and AuthNavigator as its screens whith navigation actions that go from the HomeScreen
to the LoginScreen
)