In this guide
Android Navigation Component Explained: Graphs, NavController and Safe Args
Most Android apps are several screens: a list, a detail screen, a settings screen, a form, a confirmation. Moving between those screens sounds simple until the back button behaves oddly, a screen opens twice, or an argument arrives with the wrong type and the app crashes. The Navigation Component is Google's Jetpack library for handling that movement in one consistent place, instead of scattering fragment transactions, intent extras, and back-stack fixes across every screen.
This guide explains the three pieces that matter — the graph, the host, and the controller — then covers type-safe arguments with Safe Args, deep links, and back-stack behaviour, with the common mistakes to avoid.
The problem with manual fragment transactions
Before the Navigation Component, moving to a new screen usually meant a fragment transaction written by hand: begin a transaction, replace a container, add the transaction to the back stack, commit it, and hope every other screen did the same thing the same way. That approach has several predictable problems:
- No single map of the app. There is no one place that shows every screen and every allowed move between them, so a new developer has to read code screen by screen to understand the flow.
- Arguments are untyped. Data is passed in a bundle with string keys, and the receiving screen reads it back with a cast. A misspelled key or a wrong type fails at runtime, not at compile time.
- Back behaviour is inconsistent. One screen adds its transaction to the back stack, another forgets, and a third clears the stack when it should not. The system back button then does something different on each screen.
- Deep links are bolted on. Opening a specific screen from a notification or a link needs separate intent-filter and argument-parsing code that duplicates the normal navigation path.
- Transitions and up navigation are repeated. Each screen re-implements its own animations and its own handling of the app-bar up button.
The Navigation Component replaces all of this with a declared graph: destinations and the actions between them are defined once, and one controller performs every move.
How the Navigation Component works, step by step
The library has three core concepts. Learn these three and the rest falls into place.
Step 1: Define destinations in a NavGraph
A destination is a screen the user can navigate to: a fragment in a Views-based app, or a composable function in a Compose app. The NavGraph is the map that lists every destination and the actions that connect them. An action is a named move from one destination to another, optionally carrying arguments.
Step 2: Host the graph in a NavHost
A NavHost is the container that displays the current destination. In a Views app it is a NavHostFragment placed in the activity layout. In a Compose app it is the NavHost composable. The host owns the back stack and swaps destinations in and out as the user navigates.
Step 3: Navigate with NavController
NavController is the object that performs navigation. Screens never replace fragments directly; they ask the controller to navigate to a destination or an action. Because every move goes through one controller, the back stack stays consistent, transitions apply uniformly, and the app-bar up button can be wired to the controller once.
Step 4: Pass arguments, preferably with Safe Args
Arguments are declared on the destination in the graph. Safe Args is a Gradle plugin that generates type-safe classes from those declarations, so the compiler catches a missing or mistyped argument instead of the app crashing at runtime.
Step 5: Handle the back stack deliberately
Every navigate call adds the new destination on top of the back stack, and the system back button pops it. Options on a navigate call, such as popping back to an earlier destination or avoiding duplicate copies of the same destination, let you shape that behaviour instead of accepting the default everywhere.
Step 6: Add deep links to destinations, not to activities
A deep link is attached to a destination in the graph. When the link is opened, the Navigation Component builds the correct back stack for that destination, so pressing back from a deep-linked detail screen goes to the list screen, not out of the app.
| Concept | What it is | Where it lives |
|---|---|---|
| Destination | One screen the user can reach | Declared in the NavGraph |
| NavGraph | The map of destinations and actions | XML graph file, or the NavHost builder in Compose |
| NavHost | The container showing the current destination | Activity layout (Views) or NavHost composable (Compose) |
| NavController | The object that performs navigation | Obtained from the host; used by every screen |
| Action | A named move between two destinations | Declared in the graph, between destinations |
| Safe Args | Generated type-safe argument classes | Generated from the graph at build time |
NavGraph in XML (Views-based apps)
In a Views app the graph is an XML resource. Destinations are fragments, actions connect them, and arguments are declared on the receiving destination:
<navigation
app:startDestination="@id/listFragment">
<fragment
android:id="@+id/listFragment"
android:name="com.example.app.ListFragment">
<action
android:id="@+id/action_list_to_detail"
app:destination="@id/detailFragment" />
</fragment>
<fragment
android:id="@+id/detailFragment"
android:name="com.example.app.DetailFragment">
<argument
android:name="itemId"
app:argType="string" />
</fragment>
</navigation>
The start destination is the first screen shown when the host is created. Keeping this file as the single map of the app is the main discipline the library asks for: if a move is not in the graph, it does not happen.
The host is a NavHostFragment in the activity layout, pointing at the graph:
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
app:navGraph="@navigation/app_graph"
app:defaultNavHost="true" />
Setting the default nav host flag means the system back button is intercepted by the NavController first, which is what makes back behaviour consistent across screens.
NavHost in Compose
In a Compose app there is no XML graph file. The same ideas appear as code: a NavController created with rememberNavController, a NavHost that declares a start destination and a composable destination for each screen, and navigate calls that reference routes:
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "list") {
composable("list") {
ListScreen(onItemClick = { id ->
navController.navigate("detail/" + id)
})
}
composable("detail/{itemId}") { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")
DetailScreen(itemId = itemId)
}
}
Routes here play the role of destination IDs. The pattern is the same as the XML version: declare the screens and their routes in one place, then navigate by route instead of constructing screens directly. The Compose version of Safe Args thinking still applies — keep routes as constants, parse arguments in one place, and pass parsed values (not raw route strings) down into screen composables.
NavController: navigate and popBackStack
Two calls cover most navigation:
- navigate moves forward to a destination or action. In Views code, a fragment calls findNavController().navigate with an action ID; in Compose, it calls navController.navigate with a route.
- popBackStack moves backward by removing the current destination from the stack, returning to the previous one. The system back button does this automatically when the NavHost is the default host.
// Moving forward, using a Safe Args generated direction (Views)
findNavController().navigate(
ListFragmentDirections.actionListToDetail(itemId)
)
// Moving back programmatically
findNavController().popBackStack()
A third call, navigateUp, handles the app-bar up button: it pops the stack if possible, and otherwise falls back to the behaviour the activity defines. Wiring the app bar to the controller once, in the activity, is better than handling up navigation separately in every fragment.
Navigation options on a navigate call shape the back stack:
- popUpTo removes destinations up to a given destination before navigating, which is how a login flow returns to the home screen without leaving login screens on the stack.
- launchSingleTop avoids adding a second copy of a destination that is already on top of the stack, which matters for bottom-navigation tabs that the user taps repeatedly.
- restoreState with saved state handling can restore a previously popped destination's state when returning to it, useful for tab-like flows.
Use these options deliberately and sparingly. A graph where every action has custom pop behaviour is hard to reason about; the default (push on navigate, pop on back) should be the norm, with options reserved for flows like login, onboarding, and tab switching where the default is visibly wrong.
Arguments vs Safe Args
Plain arguments work but are fragile. The sending screen puts a value in a bundle under a string key, and the receiving screen reads it with the same key and an expected type. Nothing checks that the two sides agree until the app runs — and if they disagree, the failure is a crash or a missing value on a real device.
Safe Args removes that class of bug. You declare the argument on the destination in the graph, and the plugin generates two things:
- A Directions class for the sending screen, with one method per action. The method takes the arguments as typed parameters, so a missing argument is a compile error.
- An Args class for the receiving screen, with typed properties for each argument, so there is no manual bundle reading and no cast.
// Receiving screen, Views + Safe Args: no bundle keys, no casts
val args: DetailFragmentArgs by navArgs()
val itemId: String = args.itemId
The generated classes also document the contract: reading the graph shows exactly what data each screen needs. That matters most on team or academic projects where screens are built by different people at different times.
Safe Args is not a reason to pass everything as an argument. The rules that keep it safe are:
- Pass identifiers (an item ID, a user ID), not whole objects. The receiving screen should load the object from its repository or ViewModel using the ID.
- Keep arguments small and simple: strings, numbers, booleans, and small enums. Large objects, bitmaps, and lists of complex objects do not belong in a navigation argument.
- Never pass data that must survive process death through a workaround that bypasses the graph. If a screen genuinely needs shared state, use a ViewModel scoped to the graph or a shared repository, not a global variable set before navigating.
Deep-link destinations
A deep link lets an external trigger — a notification, a web link, a widget — open a specific screen inside the app. In the Navigation Component, the deep link is declared on the destination, and the library handles the rest:
- It matches the incoming link to the destination.
- It builds the back stack leading to that destination, so back navigation from the deep-linked screen follows the app's normal flow instead of exiting.
- It parses arguments out of the link into the destination's declared arguments, so the screen receives them the same way as from an in-app navigation.
That last point is the real benefit: a detail screen reached from a notification and the same screen reached from the list go through the same argument path, so there is one code path to test instead of two. Deep links that open a screen without its parent stack — the classic notification bug where back exits the app — are a sign the link was wired to an activity instead of to a destination in the graph.
Back-stack behaviour, in plain terms
The back stack is a stack of destinations. Navigating pushes a destination on top; pressing back pops the top destination and reveals the one beneath. Three behaviours surprise developers most:
- Back after a deep link. If the stack was built correctly, back goes to the logical parent screen. If it was not, back exits the app. Test deep links with the back button, not just with the forward journey.
- Back after login or onboarding. A login screen should not remain on the stack after a successful login, or the user can press back from the home screen and land on the login screen again. Use popUpTo on the action that leaves the login flow to clear it.
- Back from a bottom-navigation tab. Tapping between tabs should not grow an endless stack of tab screens. The standard pattern is to navigate with launchSingleTop and to save and restore each tab's state, so each tab keeps its own stack position.
When back behaviour looks wrong, the fix is almost always in the graph or in the navigate options, not in overriding the back button. Overriding back handling per screen reintroduces the inconsistency the library exists to remove.
Common mistakes
Using the wrong NavController from a nested host
An app can contain more than one NavHost: a main host plus a nested host inside a tab, a bottom sheet, or a nested graph. Calling findNavController from a view that belongs to the nested host returns the nested controller, and navigating with it moves within the nested graph, not the main one. The symptom is a navigation that silently does nothing visible, or that opens the wrong screen inside a tab. The fix is to be explicit about which host a screen belongs to, and to obtain the controller from the correct host — typically the activity's main host for top-level moves, and the nested host only for moves inside that nested flow.
Passing large objects as arguments
Passing a whole data object, an image, or a long list as a navigation argument is tempting because it avoids a repository lookup. It is also fragile: arguments are stored in a bundle, which has size limits and requires the data to be parcelable, and the object becomes stale the moment the underlying data changes. Pass the ID, load fresh data in the destination's ViewModel, and the screen always shows current data even if the user returns to it later.
Navigating twice on rapid clicks
A fast double-tap on a button can fire two navigate calls before the first one completes, opening the destination twice. The user then has to press back twice to return, and the second copy may carry stale arguments. The standard fixes are to disable the button while navigation is in flight, to use launchSingleTop where a duplicate top destination is meaningless, or to debounce the click handler. This is a user-interface bug, not a graph bug, and it is easy to miss in testing because testers rarely double-tap deliberately.
Other mistakes worth checking
- Navigating after the view is destroyed. A delayed callback (a network response, a timer) that navigates after the fragment's view is gone crashes. Check the lifecycle state or cancel the callback when the view is destroyed.
- Hardcoding destination IDs in several places. If a destination ID changes, every hardcoded reference breaks. Navigate through actions and generated Directions classes, and keep route strings as constants in Compose.
- Ignoring the up button. The system back button and the app-bar up button are not the same: back pops the stack, while up should navigate to the logical parent even when the screen was reached by a deep link. Wire up navigation to the controller so both behave correctly.
- Putting business logic in navigation code. A navigate call should not also save data, validate a form, and show a message. Do the work first, then navigate once on success.
FAQ
Do I need the Navigation Component, or can I keep using fragment transactions?
You can keep using transactions, and for a single-screen app there is little to gain. The component pays off as soon as there are several screens, a bottom navigation bar, or a deep link: the graph becomes the map of the app, arguments become type-safe, and back behaviour becomes consistent without per-screen code.
Does the Navigation Component work with both Views and Compose?
Yes. Views apps use an XML graph and a NavHostFragment; Compose apps use a NavHost composable with routes. The concepts — destinations, actions or routes, a host, and a controller — are the same, which makes it practical to migrate a Views app screen by screen.
What is the difference between an action and a destination?
A destination is a screen. An action is a named move from one destination to another. Navigating by action, rather than directly to a destination ID, keeps the allowed moves explicit in the graph and lets Safe Args generate a typed Directions method for that move.
What should I do if navigate seems to do nothing?
Check three things in order: that you are using the correct NavController (especially with nested hosts), that the action or route exists in the graph, and that the call is happening while the view is alive. A silent no-op is usually the wrong controller; a crash usually names the missing action.
Can Safe Args pass custom object types?
Safe Args supports the standard argument types and parcelable types, but the recommended pattern is still to pass an ID and load the object in the destination. Custom parcelable arguments add build complexity and stale-data risk for little benefit.
How do I test navigation?
Test the graph, not just the screens: launch the app at each start destination, follow each action, press back from every destination, open each deep link, and double-tap every navigation button. Most navigation bugs are found by the back button and the double-tap, not by the forward journey.
Limitations
The Navigation Component is a navigation library, not an architecture. It does not decide how screens load data, share state, or handle errors — those remain the job of ViewModels, repositories, and the rest of the app's architecture. It also adds a learning step: the graph, the generated Safe Args classes, and the back-stack options take time to understand, and a small two-screen app may not need them.
Navigation arguments are intentionally limited in size and type. Anything large, sensitive, or shared between many screens belongs in a repository or a shared ViewModel, not in a bundle. Deep links also need care on the platform side: the intent filters and link handling that deliver a link to the app must still be configured correctly, or the graph's deep-link declaration never gets invoked.
Finally, the library cannot fix a poorly designed flow. If the screens themselves are confusing — too many steps, unclear hierarchy, no obvious home — a graph will only map that confusion precisely. Design the flow first, on paper, then encode it as a graph.