Jakallergis.com Blog

Personal thoughts and notes of a software engineer.

Proper React Navigation v5 with TypeScript

02/03/2021☕☕☕ 13 Min Read — In React Native

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 for navigate({ name: "Profile"}) or reset({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 createStackNavigators: 👇

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 and AuthNavigatorParamsList)
  • 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)

© 2019 by John A. Kallergis
Last updated: 29 Aug 2021