Technology Sep 12, 2026 · 12 min read

Jetpack Navigation Component in Android — Done Correctly

The set of Navigation Component rules I enforce on every Android project — nav graphs, type-safe arguments, back-stack hygiene, and the mistakes that show up in code review. I once took over a project where the previous team had shipped three screens with startActivity calls scattered through six f...

DE
DEV Community
by Gulshan Yadav
Jetpack Navigation Component in Android — Done Correctly

The set of Navigation Component rules I enforce on every Android project — nav graphs, type-safe arguments, back-stack hygiene, and the mistakes that show up in code review.

I once took over a project where the previous team had shipped three screens with startActivity calls scattered through six fragments, a global object holding navigation state, and a back stack that could not survive a configuration change. The bug reports were a novel: "app opens the wrong screen after rotation," "back button closes the app from a sub-screen," "double-tapping a button opens two copies of the same fragment."

All of it was a navigation problem. None of it had to exist.

The Jetpack Navigation Component, used correctly, removes an entire class of these bugs. Used carelessly — which is how most people use it — it just moves the bugs into a different file. This article is the exact setup I now use on every Android app: the dependency setup, the nav graph, type-safe arguments, the back-stack rules, and the pitfalls I find in review every single week.

Why Navigation (and Not Fragments By Hand)

The old way: each screen manually replaces a container, manually manages the back stack, and manually re-requests arguments after a rotation. The result is code that is impossible to keep consistent. Navigation Component gives you four things that replace that manual work:

  1. A single graph — every screen, every connection between screens, visible in one XML file or DSL.
  2. Automatic back-stack management — back, popUpTo, and argument restoration are handled for you.
  3. Type-safe arguments — through the Safe Args Gradle plugin, which generates typed classes instead of Bundle keys that typo silently.
  4. Consistent state restoration — the framework restores state across process death and configuration changes, if you let it.

Step 1 — Dependencies and Plugins

First, the Gradle setup. In the project root build file, add the Safe Args plugin:

// build.gradle.kts (project level)
plugins {
    id("com.android.application") version "8.5.0" apply false
    id("androidx.navigation.safeargs.kotlin") version "2.8.4" apply false
}

Then apply it in the app module and add the runtime libraries:

// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("androidx.navigation.safeargs.kotlin")
}

dependencies {
    val navVersion = "2.8.4"
    implementation("androidx.navigation:navigation-fragment-ktx:$navVersion")
    implementation("androidx.navigation:navigation-ui-ktx:$navVersion")
}

Two version notes: always pin the Navigation and Safe Args versions together — a mismatch is a real source of "method not found" crashes. And if you are on Compose, you want navigation-compose instead of the fragment artifacts, but the graph principles below are identical.

Step 2 — The Nav Graph, Written Once

The graph is the map of your app. Here is a small, realistic example — a home screen, a detail screen that takes a required argument, and a settings screen:

<!-- res/navigation/nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/nav_graph"
    app:startDestination="@id/homeFragment">

    <fragment
        android:id="@+id/homeFragment"
        android:name="com.example.app.HomeFragment"
        android:label="Home">
        <action
            android:id="@+id/action_home_to_detail"
            app:destination="@id/detailFragment" />
    </fragment>

    <fragment
        android:id="@+id/detailFragment"
        android:name="com.example.app.DetailFragment"
        android:label="Detail">
        <argument
            android:name="itemId"
            app:argType="long" />
    </fragment>

    <fragment
        android:id="@+id/settingsFragment"
        android:name="com.example.app.SettingsFragment"
        android:label="Settings" />
</navigation>

One rule I enforce: one graph file per feature, merged with app:graph inclusions, not one giant graph. A 200-node graph becomes unreadable and merge-conflict hell. Split at feature boundaries.

Step 3 — Host the NavController in the Activity

Your MainActivity becomes a single-activity host that owns the NavHostFragment:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}
<!-- res/layout/activity_main.xml -->
<fragment
    android:id="@+id/nav_host_fragment"
    android:name="androidx.navigation.fragment.NavHostFragment"
    app:defaultNavHost="true"
    app:navGraph="@navigation/nav_graph"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

app:defaultNavHost="true" is what makes the system back button interact with the NavController instead of finishing the activity. Forgetting it is the classic "back button exits the app" bug.

The NavController should be obtained from the host or via findNavController(), and it belongs to the activity's scope. Do not store it in a ViewModel, do not hold it in a companion object, do not pass it around as a parameter. Those patterns are how state leaks and crashes are born.

Step 4 — Type-Safe Arguments (Safe Args)

The whole point of Safe Args is that your code cannot pass a wrong argument. The plugin reads your graph and generates a class per action. Navigation becomes:

// In HomeFragment
val action = HomeFragmentDirections.actionHomeToDetail(itemId = 42L)
view.findNavController().navigate(action)
// In DetailFragment
val args = DetailFragmentArgs.fromBundle(requireArguments())
val itemId: Long = args.itemId

No string keys, no casting, no "itemId vs ItemId" typo that compiles fine and crashes at runtime. This is the single biggest correctness win in the whole component, and teams that skip Safe Args are the teams whose crashes are "intermittent, only in production."

Step 5 — Back-Stack Hygiene with popUpTo

Here is where most apps quietly break. Consider a login screen: after the user logs in, pressing back should not return them to the login screen. The default navigate() would do exactly that wrong thing.

The fix is popUpTo — remove the login screen from the stack when navigating forward:

val action = LoginFragmentDirections.actionLoginToHome()
view.findNavController().navigate(
    action,
    navOptions {
        popUpTo(R.id.loginFragment) { inclusive = true }
    }
)

The rules I use for back-stack behavior:

  • Logout / login transitions: popUpTo(startDestination) with inclusive = true so the whole task history is cleared.
  • Bottom-navigation tab switches: popUpTo the tab's own graph, inclusive = false, so each tab keeps its own stack.
  • Wizard flows: navigate forward normally; the back stack naturally unwinds the wizard one step at a time.
  • Never push a new copy of the start destination onto the stack.

A second, related bug: double navigation. If a user taps "open" twice quickly, you can push the same destination twice. The guard is debouncing the navigation or checking the current destination:

val controller = view.findNavController()
if (controller.currentDestination?.id == R.id.detailFragment) return
controller.navigate(action)

Without this check, rapid taps create duplicate entries, and users report "the screen opens twice."

Step 6 — Passing Real Data, Not Parcelable Silos

Arguments should be minimal — IDs, not objects. Passing an entire Parcelable model through a destination argument works, but it bloats the saved state, can blow past transaction-size limits on large models, and couples destinations to each other's data shapes.

The pattern I enforce: pass the ID via Safe Args, load the rest from the repository in the destination's ViewModel. This makes every destination able to survive process death and deep links — the argument is just the key, and the data is re-fetched, never stale.

If you must pass a small model, keep it a primitive or an ID. If your argument list grows past three fields, that is a code smell that you are modeling a screen, not a route.

Step 7 — State: SavedStateHandle and ViewModel Scope

Two state bugs dominate navigation complaints, and both have the same root cause: state stored in the wrong scope.

Bug A: data lost on rotation. If a fragment fetches data in onCreateView and holds it in a field, rotation re-creates the view and the data is gone. Fix: scope the data to a ViewModel that is scoped to the fragment or its navigation entry:

class DetailViewModel(
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    val itemId: Long
        get() = checkNotNull(savedStateHandle["itemId"])
}

When you use Safe Args, the arguments are automatically available in SavedStateHandle of the destination's ViewModelsavedStateHandle["itemId"] — which survives rotation without you touching a Bundle.

Bug B: state from the previous screen leaking. ViewModels scoped to a fragment survive as long as the destination is on the back stack. If you pop back and navigate again, the old ViewModel is gone — which is what you want. The leak happens when you scope a ViewModel to the activity to "share" data between screens. I see it everywhere, and it is how one screen's "logged in" state survives into another user's session. Scope to the navigation entry or fragment; share through a repository instead.

Step 8 — Bottom Navigation, Correctly Wired

Bottom navigation is where the Navigation Component either shines or fights you, depending on setup. The correct wiring:

val navController = (supportFragmentManager
    .findFragmentById(R.id.nav_host_fragment) as NavHostFragment)
    .navController
val bottomNav = findViewById<BottomNavigationView>(R.id.bottom_nav)
bottomNav.setupWithNavController(navController)

The gotchas I hit in review:

  • Do not call setOnItemSelectedListener and also setupWithNavController — double wiring causes double navigation and weird state.
  • Each tab needs its own graph with its own startDestination, and the tab item IDs must match the destination IDs.
  • Reselection behavior — tapping the current tab again should scroll to top, not push a duplicate. Handle the reselect in the listener after setupWithNavController, or you get the classic "tapping the tab twice opens a second home."

The one architectural warning: putting the NavController inside a BottomNavigationView fragment, rather than the activity, is a nested-graph design that doubles complexity. Keep the host at the activity level unless you genuinely need per-tab host fragments.

Step 9 — Deep Links and State Restoration

Deep links are where the framework does its best magic and teams do their worst defensive coding. A deep link is a route into your app from the outside:

<fragment
    android:id="@+id/detailFragment"
    android:name="com.example.app.DetailFragment">
    <deepLink app:uri="https://example.com/items/{itemId}" />
</fragment>

Plus the manifest intent filter so the system knows your app handles those links:

<activity
    android:name=".MainActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="example.com" />
    </intent-filter>
</activity>

Two rules: handle the deep link in the activity's onCreate and onNewIntent (singleTop launch mode — otherwise every notification tap re-creates the activity), and only pass IDs through deep links, never secrets or user-controlled objects. A deep link is user input; validate it like one.

A Quick Note on Navigation in Compose

If your project has moved to Jetpack Compose, the same principles apply with different plumbing. You use NavHost and composable entries, arguments come through navArgument and SavedStateHandle, and Safe Args still generates the typed directions. The two differences that trip people up:

  • Get the NavController from the activity, not from composition hacks. rememberNavController() should be created at the top of the NavHost's owner composable, never inside a child that can recompose and reset it.
  • Back-stack behavior is expressed in the same popUpTo / launchSingleTop options, passed to navController.navigate(route, navOptions). The rules in Step 5 and Step 8 apply unchanged — a Compose app that drops popUpTo on login has the same back-to-login bug, just written in Kotlin instead of XML.

The navigation-compose artifacts also handle deep links and type-safe routes if you use the kotlinx.serialization-based routes in newer versions. The architectural rules — pass IDs not objects, keep state out of the activity scope, guard against double navigation, test process death — are identical. If you know the fragment rules above, Compose navigation is mostly a syntax change.

Step 10 — Process Death, the Final Boss

The most common production crash in navigation apps: the process is killed in the background, the user returns, and the app tries to restore a fragment whose state references an argument that no longer exists — usually because the argument was passed as an in-memory object or because the destination was removed in a newer version of the graph.

The fix set:

  • Only pass primitives and IDs through arguments (Step 6), so they serialize cleanly.
  • Give every fragment and destination stable IDs — never generate them at runtime.
  • Version your graphs — when you remove a destination, stale deep links and old saved states can still reference it; handle the "destination not found" case gracefully in the NavController.OnDestinationChangedListener.
  • Test the process-death path with the developer option "Don't keep activities" during QA. If it survives that, it survives real life.

The Code-Review Checklist

I refuse a navigation change unless it passes this list:

  • [ ] Navigation goes through the NavController, not startActivity or manual fragment transactions.
  • [ ] All arguments use Safe Args types; no raw Bundle keys.
  • [ ] Arguments are primitives or IDs, not Parcelable models.
  • [ ] Back stack behavior uses popUpTo deliberately on login, logout, and wizard completions.
  • [ ] No duplicate navigation; double-tap guarded at every button.
  • [ ] ViewModels are scoped to navigation entry or fragment, never activity.
  • [ ] Bottom navigation wired once, with reselect handled explicitly.
  • [ ] Deep links validated, handled in onCreate and onNewIntent, pass only IDs.
  • [ ] Process-death restore tested with "Don't keep activities" enabled.
  • [ ] Graphs are per-feature, not one giant file.

That project I inherited — the one with startActivity in six fragments and a global state object — took two weeks to rebuild on the Navigation Component. The rotation bugs disappeared, the duplicate-fragment reports stopped, and the back stack behaved like a back stack for the first time. The user-visible result was not a feature; it was simply the app working, which is what navigation done correctly always looks like.

One last thought on team habits: navigation rules only survive if they are written down. I keep a short NAVIGATION.md in the repo — the back-stack table from Step 5, the double-navigation guard from Step 5, the state-scoping rule from Step 7 — and the code-review checklist above lives at the top of the pull-request template. New teammates produce fewer regressions, and the "how do I navigate to X" questions disappear from chat. A graph is only as good as the team that reads it.

Navigation is the skeleton of your app. Do it correctly once — with a real graph, type-safe arguments, and honest back-stack rules — and every screen you build after that is just a destination that already fits.

*Gulshan Yad

DE
Source

This article was originally published by DEV Community and written by Gulshan Yadav.

Read original article on DEV Community
Back to Discover

Reading List