NavController — Essence, Methods and Navigation Management in Jetpack Compose

Author: IT Sectr Published: 2026-06-29 Reading time: 7 min

NavController is the central component of the Navigation Compose library that manages the navigation stack and back stack state in Android applications. Through NavController, transitions between screens, returning to previous pages, and data transfer between routes are performed. According to Android Developers (2025), NavController is a mandatory element of any Compose application with more than one screen. The controller is created via rememberNavController(), passed to NavHost, and is available for calling navigate() from any point in the composition. Built-in SavedStateHandle support automatically saves the ViewModel state during reconfiguration.

Key Takeaways

  • NavController — the central Compose navigation controller, managing back stack and transitions between screens
  • navigate() — the main method for navigating to a route with NavOptions support for stack management
  • popBackStack() — return to the previous screen with optional cleanup up to a specified route
  • SavedStateHandle — integration with ViewModel for preserving screen state during navigation
  • currentBackStackEntryAsState() — observing the current route for UI synchronization

What is NavController in Jetpack Compose?

NavController is a class from the Navigation Compose library that implements the navigation controller for Compose applications. NavController manages the NavBackStackEntry stack, where each entry contains the route, arguments, and screen state. The controller supports basic navigation operations: transition, return, replacement, and cleanup.

Unlike the View system, where navigation went through FragmentManager or Intent, NavController works exclusively in the Compose context. The back stack is stored as a NavDestination graph rather than a Fragment stack. This eliminates the overhead of creating and destroying Fragments and simplifies testing — NavController can be mocked via TestNavHostController.

NavController is closely tied to NavHost — a container that renders the current screen from the graph. Without NavHost, NavController cannot display composable functions but retains the ability to manage the stack. In a typical architecture, NavController is created at the Activity or main composable level and passed down the composition tree through parameters.

According to Google, NavController has gone through several major releases. Version 2.8.0 added Type-Safe Navigation, version 2.9.0 added support for predictive back gesture (Android 14+). The controller is compatible with Material3 Scaffold and BottomNavigation. For multi-module projects, NavController is passed through DI (Hilt/Koin) or constructor parameters.

NavController is created via the composable function rememberNavController(). The function returns an instance of NavHostController (a subclass of NavController) tied to the lifecycle of the current composable. When leaving the composition, the controller is cleared. To preserve the controller during reconfiguration, use rememberSaveable or ViewModel.

kotlin
@Composable
fun MyApp() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = "main"
    ) {
        composable("main") { MainScreen(navController) }
        composable("details") { DetailsScreen(navController) }
    }
}

NavController configuration includes: NavHostController (main), TestNavHostController (testing) and ScopedNavController (child for nested graphs). For BottomNavigation, NavController should be single for the entire app — creating a new controller in each tab will result in stack loss. To pass the controller to nested screens, use a function parameter rather than CompositionLocalProvider to maintain readability.

For navigation testing, use TestNavHostController with compose-test-rule. The controller allows setting the initial route and verifying that navigate() triggered the expected transition. Testing NavController does not require an emulator — it works with Compose Test Semantics matchers.

The navigate(route: String) method is the primary navigation mechanism in NavController. It accepts a route string, optional NavOptions and Navigator.Extras. NavOptions control transition behavior: launchSingleTop (don't duplicate the route in the stack), popUpTo (clear the stack up to a route), restoreState (restore previous state).

NavOptions are set via builder syntax: NavOptionsBuilder. Main parameters: popUpTo (route + inclusive/saveState), launchSingleTop (Boolean, true — don't create duplicates), restoreState (restore state on return). Without popUpTo, each navigate() adds an entry to the stack, leading to back stack accumulation and incorrect Back button behavior.

kotlin
navController.navigate("profile/42") {
    popUpTo("main") { saveState = true }
    launchSingleTop = true
    restoreState = true
}

Navigator.Extras allows passing additional data not part of the route: shared elements for animation, Intent flags, Pac-Man bundle. Extras are rarely used — mainly for integration with Accompanist Animation or custom Navigators. For most scenarios, a route string and NavOptions are sufficient.

popBackStack: Managing Return and Stack Cleanup

popBackStack() is the method for returning to the previous screen. Without arguments, it removes the top stack entry and returns true if removal was successful. If the stack is empty, the method returns false and the Activity closes (similar to super.onBackPressed()).

The overloaded version popBackStack(route: String, inclusive: Boolean) removes all entries up to the specified route. If inclusive = true, the specified route itself is also removed. The method returns Boolean — true if entries were found and removed. The inclusive version is useful for “exit to root screen” scenarios after authorization or order completion.

MethodDescriptionExample
popBackStack()Return one screen backnavController.popBackStack()
popBackStack(route, false)Clear up to route (route remains)popBackStack(“home”, false)
popBackStack(route, true)Clear up to and including routepopBackStack(“home”, true)
navigate(route) { popUpTo(route) { inclusive = true } }Navigate with full cleanupnavigate(“login”) { popUpTo(0) { inclusive = true } }

To handle the system Back button (hardware back button), use BackHandler from Compose. BackHandler takes enabled and onBack — a callback invoked on press. For Android 14+, PredictiveBackGesture is used, integrated via NavController from version 2.9.0. Predictive back adds a return preview animation.

SavedStateHandle: Preserving Screen State

SavedStateHandle is a mechanism for preserving ViewModel state during navigation and reconfiguration. NavController automatically provides SavedStateHandle for each NavBackStackEntry. Through SavedStateHandle, ViewModel stores screen state and restores it on return (restoreState = true).

In Navigation Compose, SavedStateHandle is used together with ViewModel: ViewModel is initialized via SavedStateHandle, which is passed from backStackEntry. When navigating to another screen and returning (with restoreState), ViewModel receives the saved state rather than being created anew. This is critical for screens with data input, filters, or scrolling.

kotlin
class ProfileViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    val userId: String = savedStateHandle.get<String>("userId") ?: ""
    var searchQuery by savedStateHandle.getStateFlow("search", "")
        .collectAsState()
}

SavedStateHandle supports primitive types, String, Bundle and Parcelable. For complex objects, save only IDs and load full data from the repository. SavedStateHandle limit is about 1 MB, exceeding it causes TransactionTooLargeException. For large volumes, use Room or DataStore instead of saving in the handle.

Important: SavedStateHandle preserves state only when restoreState = true is used in NavOptions. If restoreState is not specified, ViewModel is created anew with default values on return. For BottomNavigation switching with restoreState, NavController preserves each tab's state and restores it upon reselection.

Observing Current Route via currentBackStackEntryAsState

currentBackStackEntryAsState() is a function that returns State<NavBackStackEntry?>, which updates on every change of the current route. This is the primary mechanism for UI synchronization with navigation: BottomNavigation highlights the active item, Toolbar updates the title, Drawer closes on transition.

The function works through snapshotFlow and collectAsState: when the back stack changes, Compose recomposes subscribed elements. Important: currentBackStackEntryAsState() updates only after the transition animation completes. For immediate updates, use currentDestination, which changes synchronously with navigate() but does not support state.

kotlin
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route

Text(
    text = when (currentRoute) {
        "home" -> "Home"
        "profile" -> "Profile"
        else -> ""
    }
)

To access arguments of the current route, use navBackStackEntry?.arguments. This is convenient in BottomNavigation: selectedItem is computed based on currentRoute. For navigation debugging, use NavController.addOnDestinationChangedListener() which logs each transition. In production, avoid subscriptions inside a large number of composables — create a single source in ViewModel and pass State to UI.

Frequently Asked Questions

Can I create multiple NavControllers in one Activity?

Technically yes, but not recommended. A single NavController ensures consistent back stack and simplifies debugging. Multiple controllers are justified only for nested graphs with separate navigation (e.g., modal bottom sheet with its own stack).

How to pass NavController through ViewModel?

Pass NavController to ViewModel via constructor or DI. However, it's better to pass only callback functions (onNavigate, onBack) rather than NavController itself — this simplifies testing. For events, use Channel<NavEvent> in ViewModel and collect in UI.

Why doesn't navigate work after an async operation?

The issue is lifecycle: if NavController is not yet initialized (NavHost not built), navigate() is ignored. Use LaunchedEffect to call navigation after data loading, not inside a coroutine with arbitrary lifecycle.

How to clear the entire back stack and navigate to a new screen?

Call navController.navigate(“target”) { popUpTo(0) { inclusive = true } }. The popUpTo(0) parameter clears the stack completely, inclusive = true removes the starting entry as well. The launchSingleTop = true flag prevents duplicate routes.

What's the difference between NavHostController and NavController?

NavHostController is a subclass of NavController with additional methods for NavHost (e.g., setOnBackStackChangedListener). NavController is the base class that can be used outside NavHost for programmatic stack management. In most cases, NavHostController is used.

Summary

  • NavController — the central component of Navigation Compose, managing the route stack and transitions between screens
  • navigate() performs transitions with popUpTo, launchSingleTop and restoreState settings via NavOptions
  • popBackStack() manages return: single step or mass cleanup up to a specified route with inclusive
  • SavedStateHandle integrates with ViewModel for automatic screen state preservation during navigation
  • currentBackStackEntryAsState() provides reactive observation of the current route for UI synchronization
  • BackHandler handles the system Back button, and PredictiveBackGesture is supported from NavController 2.9.0
  • For testing, use TestNavHostController with compose-test-rule and Semantics matchers

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also