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.
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.
On this page
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.
// build.gradle.kts
implementation("androidx.navigation:navigation-compose:2.7.7")@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)
}
}
}String routes get messy. Define type-safe destinations:
// 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())// 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))
}For complex objects, don't serialize to route strings. Pass the ID and let the destination fetch:
// 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)
}Navigate and clear back stack (after login):
navController.navigate("task_list") {
popUpTo("login") { inclusive = true } // Remove login from back stack
}Navigate without duplicating the destination:
navController.navigate("settings") {
launchSingleTop = true // Don't create multiple instances of settings
}Navigate up (handle back button):
// In Compose
BackHandler { navController.navigateUp() }
// Or with Scaffold top bar
TopAppBar(
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.Default.ArrowBack, "Back")
}
}
)Navigation doesn't have a built-in "startActivityForResult" equivalent. The recommended pattern is shared ViewModel or
SavedStateHandle// 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")
}
}For feature modules or large apps, organize routes into nested graphs:
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.
Passing NavController through composable parameters. Instead, pass lambdas:
// 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.
popUpToNavControllerSavedStateHandleSudarshan 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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read