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 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.
@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.
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() 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.
| Method | Description | Example |
|---|---|---|
| popBackStack() | Return one screen back | navController.popBackStack() |
| popBackStack(route, false) | Clear up to route (route remains) | popBackStack(“home”, false) |
| popBackStack(route, true) | Clear up to and including route | popBackStack(“home”, true) |
| navigate(route) { popUpTo(route) { inclusive = true } } | Navigate with full cleanup | navigate(“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 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.
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.
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.
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
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).
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.
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.
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.
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
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.
Read also