Skip to main content

In this post, I’m going to describe some of the pitfalls of Jetpack Navigation in Compose, and how to avoid them. Understanding these problems and finding solutions has been quite a journey. This post will try to guide you through that journey – including some examples of bad practices!

Let’s Begin

Suppose we have an app with a “Home” screen and a “Detail” screen.

We can use a NavHost and NavController to navigate between these screens. The startDestination is home, and the user is taken to the detail destination when they click a button.

@Composable
fun MyNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(
                onNavigateToDetailScreen = {
                    navController.navigate("detail")
                }
            )
        }
        composable("detail") {
            DetailScreen()
        }
    }
}

Conditional Navigation

What if the user must log in before they get to the home screen? When logged in, they see the home screen; otherwise, they see the login screen.

Assume we have a ViewModel which keeps track of the logged-in state:

class AppViewModel() {
    val hasLoggedIn: Flow<Boolean> = ...
    fun setHasLoggedIn(value: Boolean){ ...
}

We could render the NavHost if the user is logged-in or otherwise render the LoginScreen:

@Composable
fun ComposeNavigationApp(
    viewModel: AppViewModel
) {
    val hasLoggedIn by viewModel.hasLoggedIn
        .collectAsState(initial = false)

    if (hasLoggedIn) {
        MyNavHost()
    } else {
        LoginScreen(
            onLoginClicked = {
                viewModel.setHasLoggedIn(true)
            }
        )
    }
}

This works, although there is a subtle problem. The initial value of hasLoggedIn is false, so the LoginScreen is rendered momentarily even if the user is logged in.

View states

There are three possible states that we need to account for:

  1. hasLoggedIn = true
  2. hasLoggedIn = false
  3. hasLoggedIn = unknown

An idiomatic way to handle this in Kotlin/Compose is to define these states in a sealed class and then expose them to our Composable:

sealed class ViewState {
    object Loading: ViewState() // hasLoggedIn = unknown
    object LoggedIn: ViewState() // hasLoggedIn = true
    object NotLoggedIn: ViewState() // hasLoggedIn = false
}

class AppViewModel() {
    val hasLoggedIn: Flow<Boolean> = ...

    val viewState = hasLoggedIn.map { hasLoggedIn ->
        if (hasLoggedIn) {
            ViewState.LoggedIn
        } else {
            ViewState.NotLoggedIn
        }
    }.stateIn(initial = ViewState.Loading, ...
}

@Composable
fun ComposeNavigationApp(
    viewModel: AppViewModel
) {
    val viewState by viewModel.viewState.collectAsState()

    when(viewState) {
        is ViewState.Loading -> {
            LoadingView()
        }
        is ViewState.LoggedIn -> {
            MyNavHost()
        }
        is ViewState.NotLoggedIn -> {
            LoginScreen()
        }
    }
}

By introducing LoadingView, we now have a screen to display when we’re unsure if the user is logged in.

Using sealed classes to represent screen/view states is a pretty common practice in Compose, and it can make it easy to understand all the states under which a screen will be rendered. However, rendering different screens based on these states is a sort of reactive navigation. Using the NavController maps more closely to imperative navigation. Mixing reactive and imperative paradigms can lead to some very confusing problems!

Navigating Deeper

What if we want to be able to navigate from our login screen to another screen, maybe a terms screen? In our implementation above, the LoginScreen() function exists outside of the NavHost, so if we ask the NavController to navigate to terms, we’re going to encounter an error.

Maybe we could set login as our startDestination and navigate to home when the user logs in?

Principles of navigation

Luckily for us, Google provide navigation principles that we can follow. Their very first point is:

Every app you build has a fixed start destination.

The principles go on to say:

An app might have a one-time setup or series of login screens. These conditional screens should not be considered start destinations because users see these screens only in certain cases.

One of the main reasons not to use temporary/conditional screens as a start destination is to support deep linking properly.

Ian Lake touches on this in the following video:

In summary:

Deep links have multiple entry points, so users won’t always see your start destination as their first screen. Android itself will restore users back to exactly where they were.. the start destination.. is automatically put on the backstack when you login. Hitting back and returning to your login screen isn’t a good look. If you can be restored at any destination by the Android system, then each destination that requires login needs to conditionally navigate to your login screen. If the user logs in successfully, the login screen gets popped off the back stack, and tada, you’re back where you were.

Note: Deep links are not the only reason to avoid having a conditional screen as your start destination. Performance is another consideration. Usually, conditional screens such as login or onboarding are only shown to the user once. Most of the time, users who launch the app will go directly to the main content. Therefore, navigating the user via an immediately dismissed conditional screen wastes precious start-up time!

Ok, we’re not supposed to use login as a startDestination. What other bad ideas can we come up with?

Multiple NavHosts

We could move the login & terms destinations into their own NavHost:

when (viewState) {
    is ViewState.Loading -> {
        LoadingView()
    }
    is ViewState.LoggedIn -> {
        HomeNavHost() // contains home & detail destinations
    }
    is ViewState.NotLoggedIn -> {
        LoginNavHost() // contains login & terms destinations
    }
}

So, we render a different NavHost based on our viewState, and each NavHost encapsulates its destinations. In theory, this sounds good. But it gets complicated, and multiple NavHosts are not recommended!

One of the problems with having multiple NavHosts is that you have to remember which one is active before navigating somewhere. So, for example, while logged in, if you try to navigate to the terms screen (in the HomeNavHost), you’ll get an error – as your HomeNavHost does not know the terms destination.

As you build up more states, more destinations and possibly more NavHosts, it’s going to get even harder to keep track.

An example:

Let’s say there’s a new requirement in your app. There’s a log-out button on the home screen; when the user logs out, it should direct them to the terms screen.

We barely have to write code to know this will be a pain.

when (viewState) {
    is ViewState.LoggedIn -> {
        HomeNavHost( // contains home & detail destinations
            onLogOut = {
                // navigate to terms here?

                viewModel.setHasLoggedIn(false)

                // navigate to terms here?
            }
        )
    }
    is ViewState.NotLoggedIn -> {
        LoginNavHost() // contains login & terms destinations
    }
}

Where do we call navController.navigate(terms)?

If we do this before viewModel.setHasLoggedIn(false), we’ll get an error, as the HomeNavHost doesn’t know about the terms screen!

We could fix that by adding the terms destination to the HomeNavHost. Now, we’ll be able to navigate to the terms screen. Then our viewState will change to NotLoggedIn, our NavHost will change to LoginNavHost, and we’ll no longer be looking at the terms screen!

If we do this after viewModel.setHasLoggedIn(false), we’ll have to hope we’ve completed the log-out process, changed the viewState, and rendered the LoginNavHost in time to handle our navigate call. But, unfortunately, there’s plenty of room for this to go wrong. Flaky errors abound!

The issue is that we rely on the ViewState as our source of truth for which screen to render. But we sometimes rely on our NavController to decide which screen to render. So we have multiple sources of truth. Someone must be lying!

Note: There is another typical case where it’s tempting to use multiple NavHosts (when implementing Bottom Navigation). I hope to cover this in a separate post.

More view states

So what should we do? We could introduce a new ViewState to track when we should go to the terms screen after the user has logged out.

sealed class ViewState {
    object Loading: ViewState() // hasLoggedIn = unknown
    object LoggedIn: ViewState() // hasLoggedIn = true
    object NotLoggedIn: ViewState() // hasLoggedIn = false
    object Terms: ViewState()
}

But this is another bad idea. How many states might we end up with? For example, we can have a ViewState of NotLoggedIn and imperatively navigate to the terms screen via the NavController. Or, we can have a ViewState of Terms and render the terms screen. But what happens when the user wants to navigate away from the “terms” screen? Nothing will happen if we call navController.popBackStack() and are in ViewState.Terms. It’s a mess!

Multiple NavHosts are bad

Having multiple NavHosts is generally a bad idea. It can be hard to keep track of which one is current. In addition, rendering different NavHosts based on some state introduces the multiple sources of truth problem. For example, what is the right screen to render? The screen the navController navigated to, or the one the ViewState wants to display?

Single NavHost

Here’s a SingleNavHost, which contains all destinations:

@Composable
fun SingleNavHost(
    startDestination: String,
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = startDestination
    ) {
        composable("home") {
            HomeScreen()
        }
        composable("detail") {
            DetailScreen()
        }
        composable("login") {
            LoginScreen()
        }
        composable("terms") {
            TermsScreen()
        }
    }
}

A single NavHost means we don’t need to introduce intermediate ViewStates to render content not backed by a destination in the NavHost. Instead, all our destinations are available and are navigable via navController.navigate(). You can pop the back stack if you arrive at a screen and want to navigate away from it. You don’t have to consider whether your NavHost is still available. You have a single source of truth for navigation.

Sounds good, but how do we implement conditional navigation with a single NavHost?

Multiple Start Destinations

We could try using a different startDestination, based on our logged in state: 

@Composable
fun ComposeNavigationApp(
    viewModel: AppViewModel
) {
    val hasLoggedIn by viewModel.hasLoggedIn
        .collectAsState(initial = false)

    if (hasLoggedIn) {
        SingleNavHost(startDestination = "home")
    } else {
        SingleNavHost(startDestination = "login")
    }

But this is cheating! That’s not a single NavHost. There are two instances, and we render a different one depending on our logged-in state. So we have a dynamic start destination. The navigation principles already state that we should have a fixed start destination. Let’s look for a better solution!

A Better Way

A better way is to stick to our single NavHost and move the conditional navigation logic to the home screen!

Since every app should have a fixed start destination, and we shouldn’t consider our conditional screen a start destination, what other choice do we have?

@Composable
fun SingleNavHost(
    viewModel: AppViewModel,
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(
                viewModel = viewModel,
                onNavigateToLoginScreen = {
                    navController.navigate("login")
                }
            )
        },
        composable("login") {
            LoginScreen()
        }
    ...

@Composable
fun HomeScreen(
    viewModel: AppViewModel,
    onNavigateToLoginScreen: () -> Unit = {}
) {

    val viewState by viewModel.viewState.collectAsState()

    when (viewState) {
        AppViewModel.ViewState.Loading -> {
            LoadingView()
        }
        AppViewModel.ViewState.NotLoggedIn -> {
            LaunchedEffect(viewState) {
                onNavigateToLoginScreen()
            }
        }
        AppViewModel.ViewState.LoggedIn -> {
            HomeScreenContent()
        }
    }
}

Huh, that was pretty easy. If the ViewState changes to NotLoggedIn, we navigate to the login screen.

We’ve already realised one significant advantage of this approach. Once our ViewState changes and causes us to navigate away from the home screen, the home screen no longer observes viewState, so subsequent changes won’t affect us until we return. So if we’re on the login screen and want to navigate to terms, we don’t have to worry about viewState changes swapping the NavHost out from under us.

Secondly, once the user has finished logging in via the login screen, we can pop the back stack and end up back on the home screen. The home screen observes the viewState again, recognises we’re logged in, and renders the HomeScreenContent(). Even better, our login screen is no longer on the back stack! So we can’t accidentally return to it with the back button.

Handling login failure

There’s one last piece remaining. As it stands, the user launches the app, and the home screen renders, determines the user is not logged in and navigates them to the login screen. But what if the user presses back? The system will pop the back stack, and we’ll end up on the home screen – which will then direct us back to the login screen. So, we’re stuck in a loop!

There are a couple of ways to deal with this, depending on what you think should occur when the user chooses not to log in from the login screen.

The most straightforward approach is to exit the app if the user presses back from the login screen:

@Composable
fun LoginScreen(onExitApp: () -> Unit) {

    BackHandler(enabled = true) {
        onExitApp()
    }

}

onExitApp can be passed up the call hierarchy until you reach your host Activity, which can call Activity.finish(). On Android S (API 31) and above, the system calls Activity.moveTaskToBack() rather than finish() – so you might want to follow that behaviour. See the docs on onBackPressed and moveTaskToBack for more info.

Alternatively, you could store some state on a view model when the user navigates back from the login screen – to indicate that login was unsuccessful. The home screen could observe this state and render some Composable content showing that the user must log in. If they press back again, the system will exit the app for us.

Conclusion

Using a single NavHost to hold our possible destinations makes it easy to reason about our code. As a result, we don’t run into unexpected errors due to unavailable destinations and don’t have to keep track of which NavHost is the current one.

Moving our conditional navigation logic into the destinations that require it means we shift back to an imperative navigation style with a single source of truth. We call navController.navigate() where needed, rather than sometimes observing the ViewState to render screens and other times using the nav controller.

Lastly, by using an appropriate start destination, we ensure that the user won’t be presented with unexpected screens when following a deep link or using the back button. 

Happy coding!