# 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:

```kotlin
// 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:

```kotlin
// 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:

```xml
<!-- 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`:

```kotlin
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }
}
```

```xml
<!-- 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:

```kotlin
// In HomeFragment
val action = HomeFragmentDirections.actionHomeToDetail(itemId = 42L)
view.findNavController().navigate(action)
```

```kotlin
// 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:

```kotlin
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:

```kotlin
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:

```kotlin
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 `ViewModel` — `savedStateHandle["itemId"]` — which survives rotation without you touching a Bundle.

**Bug B: state from the previous screen leaking.** `ViewModel`s 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:

```kotlin
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:

```xml
<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:

```xml
<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.
- [ ] `ViewModel`s 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.

---

*Originally published at [https://www.misar.blog/@mrgulshanyadav/articles/jetpack-navigation-component-in-android-done-correctly](https://www.misar.blog/@mrgulshanyadav/articles/jetpack-navigation-component-in-android-done-correctly)*

