Skip to content
All posts
May 7, 20264 min read

Compose Navigation: Patterns, Arguments, and Back Stack Management

Jetpack Compose Navigation replaces the Fragment back stack with a declarative nav graph. Here's how to set it up correctly, pass arguments between screens, handle deep links, and manage the back stack without common pitfalls.

AndroidJetpack ComposeKotlinArchitecture
Share:

Navigation in Compose is cleaner than Fragment navigation but has its own set of gotchas. Here's how to use it correctly from the start.


Basic Setup

kotlin
// build.gradle.kts
implementation("androidx.navigation:navigation-compose:2.7.7")
kotlin
@Composable
fun AppNavHost(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = "task_list"
    ) {
        composable("task_list") {
            TaskListScreen(navController = navController)
        }
        composable("task_detail/{taskId}") { backStackEntry ->
            val taskId = backStackEntry.arguments?.getString("taskId") ?: return@composable
            TaskDetailScreen(taskId = taskId, navController = navController)
        }
        composable("settings") {
            SettingsScreen(navController = navController)
        }
    }
}

Type-Safe Navigation With NavArgs

String routes get messy. Define type-safe destinations:

kotlin
// Define destinations as sealed classes
sealed class Screen(val route: String) {
    object TaskList : Screen("task_list")
    object Settings : Screen("settings")
    
    data class TaskDetail(val taskId: String) : Screen("task_detail/{taskId}") {
        fun createRoute() = "task_detail/$taskId"
        
        companion object {
            val arguments = listOf(
                navArgument("taskId") { type = NavType.StringType }
            )
        }
    }
}

// In NavHost
composable(
    route = Screen.TaskDetail("").route, // "task_detail/{taskId}"
    arguments = Screen.TaskDetail.arguments
) { backStackEntry ->
    val taskId = backStackEntry.arguments?.getString("taskId") ?: return@composable
    TaskDetailScreen(taskId = taskId)
}

// Navigation
navController.navigate(Screen.TaskDetail(taskId = task.id).createRoute())

Passing Arguments: Simple Values

kotlin
// String argument
composable(
    route = "task_detail/{taskId}",
    arguments = listOf(navArgument("taskId") { type = NavType.StringType })
) { backStackEntry ->
    val taskId = backStackEntry.arguments?.getString("taskId") ?: ""
    TaskDetailScreen(taskId = taskId)
}

// Optional argument with default
composable(
    route = "task_list?filter={filter}",
    arguments = listOf(
        navArgument("filter") {
            type = NavType.StringType
            defaultValue = "ALL"
            nullable = false
        }
    )
) { backStackEntry ->
    val filter = backStackEntry.arguments?.getString("filter") ?: "ALL"
    TaskListScreen(initialFilter = TaskFilter.valueOf(filter))
}

Passing Complex Objects

For complex objects, don't serialize to route strings. Pass the ID and let the destination fetch:

kotlin
// DON'T: Pass the full object in the route
// route = "task_detail/${Json.encode(task)}" ← Bad

// DO: Pass the ID, fetch in destination
composable("task_detail/{taskId}") { backStackEntry ->
    val taskId = backStackEntry.arguments?.getString("taskId") ?: return@composable
    val viewModel: TaskDetailViewModel = hiltViewModel()
    
    LaunchedEffect(taskId) {
        viewModel.loadTask(taskId) // ViewModel fetches from repository
    }
    
    TaskDetailScreen(viewModel = viewModel)
}

Back Stack Management

Navigate and clear back stack (after login):

kotlin
navController.navigate("task_list") {
    popUpTo("login") { inclusive = true } // Remove login from back stack
}

Navigate without duplicating the destination:

kotlin
navController.navigate("settings") {
    launchSingleTop = true // Don't create multiple instances of settings
}

Navigate up (handle back button):

kotlin
// In Compose
BackHandler { navController.navigateUp() }

// Or with Scaffold top bar
TopAppBar(
    navigationIcon = {
        IconButton(onClick = { navController.navigateUp() }) {
            Icon(Icons.Default.ArrowBack, "Back")
        }
    }
)

Returning Data Between Screens

Navigation doesn't have a built-in "startActivityForResult" equivalent. The recommended pattern is shared ViewModel or

code
SavedStateHandle
:

kotlin
// In the destination that returns data
@HiltViewModel
class AddTaskViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val repository: TaskRepository
) : ViewModel() {
    
    fun saveTask(title: String) = viewModelScope.launch {
        val task = repository.createTask(title)
        // Set result for the caller
        savedStateHandle["new_task_id"] = task.id
    }
}

// In the calling screen
val backStackEntry = navController.getBackStackEntry("task_list")
val savedStateHandle = backStackEntry.savedStateHandle
val newTaskId: String? = savedStateHandle.get<String>("new_task_id")

LaunchedEffect(newTaskId) {
    newTaskId?.let { id ->
        // Highlight the new task
        savedStateHandle.remove<String>("new_task_id")
    }
}

Nested Navigation Graphs

For feature modules or large apps, organize routes into nested graphs:

kotlin
NavHost(navController, startDestination = "main") {
    navigation(startDestination = "task_list", route = "main") {
        composable("task_list") { TaskListScreen(navController) }
        composable("task_detail/{taskId}") { ... }
    }
    
    navigation(startDestination = "profile", route = "user_section") {
        composable("profile") { ProfileScreen(navController) }
        composable("edit_profile") { EditProfileScreen(navController) }
    }
    
    composable("settings") { SettingsScreen(navController) }
}

Each nested graph can be in a separate file and even a separate feature module.


Common Mistakes

Passing NavController through composable parameters. Instead, pass lambdas:

kotlin
// Bad — tight coupling
@Composable
fun TaskListScreen(navController: NavController) {
    Button(onClick = { navController.navigate("settings") })
}

// Good — decoupled
@Composable
fun TaskListScreen(onNavigateToSettings: () -> Unit) {
    Button(onClick = onNavigateToSettings)
}

// NavController stays at the NavHost level
composable("task_list") {
    TaskListScreen(onNavigateToSettings = { navController.navigate("settings") })
}

Not handling null arguments. Always provide default values or early returns for nullable arguments from back stack entries.


Takeaways

  • Type-safe route objects (sealed class) beat raw strings for maintainability
  • Pass IDs in routes, not full objects — let destinations fetch their data
  • code
    popUpTo
    manages the back stack for login flows and tab navigation
  • Pass
    code
    NavController
    only at the NavHost level — use lambdas below that
  • code
    SavedStateHandle
    is the mechanism for returning data from a destination
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this