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.
Navigation in Compose has rough edges. After setting it up in 10+ apps, here is the pattern that avoids the common pain points — type-safe routes, bottom nav integration, and ViewModel scoping.
On this page
Compose Navigation is not as clean as it should be. The API has improved with each release, but there are still decisions you make in every project that aren't obvious until you've made the wrong call once.
Here's the setup that works.
String-based routes are error-prone and not refactor-safe. Use a sealed class hierarchy instead:
sealed class Screen(val route: String) {
object Home : Screen("home")
object Profile : Screen("profile")
data class Detail(val id: String) : Screen("detail/{id}") {
fun createRoute(id: String) = "detail/$id"
}
object Settings : Screen("settings")
}This gives you autocomplete, compile-time safety, and a single source of truth for all routes.
@Composable
fun AppNavGraph(
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen(
onNavigateToDetail = { id ->
navController.navigate(Screen.Detail("").createRoute(id))
}
)
}
composable(
route = Screen.Detail("").route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("id") ?: return@composable
DetailScreen(id = id)
}
composable(Screen.Settings.route) { SettingsScreen() }
composable(Screen.Profile.route) { ProfileScreen() }
}
}The pattern for bottom nav that preserves back stack state per tab:
@Composable
fun MainScreen() {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val bottomNavItems = listOf(Screen.Home, Screen.Profile, Screen.Settings)
Scaffold(
bottomBar = {
NavigationBar {
bottomNavItems.forEach { screen ->
NavigationBarItem(
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { /* icon */ },
label = { Text(screen.route) }
)
}
}
}
) { paddingValues ->
AppNavGraph(navController = navController)
}
}The
saveState = truerestoreState = trueFor shared state between screens in the same flow, scope the ViewModel to the nav graph rather than individual screens:
composable(Screen.Checkout.route) { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry("checkout_graph")
}
val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
CheckoutScreen(viewModel = sharedViewModel)
}This means the ViewModel survives navigation between checkout steps but is cleared when the user exits the checkout flow entirely.
Don't pass complex objects through navigation arguments. Pass IDs, fetch data in the destination.
// Wrong — serializing full objects is fragile
navController.navigate("detail/${json.encode(user)}")
// Right — pass the ID, fetch in the ViewModel
navController.navigate(Screen.Detail("").createRoute(user.id))
// In DetailViewModel
@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: UserRepository
) : ViewModel() {
private val userId = savedStateHandle.get<String>("id")!!
val user = repository.getUser(userId).stateIn(viewModelScope, ...)
}Add deep link support per composable:
composable(
route = Screen.Detail("").route,
arguments = listOf(navArgument("id") { type = NavType.StringType }),
deepLinks = listOf(
navDeepLink { uriPattern = "https://yourapp.com/detail/{id}" }
)
) { backStackEntry ->
DetailScreen(id = backStackEntry.arguments?.getString("id") ?: "")
}Then declare the intent filter in your manifest. Deep links work out of the box with this setup.
After 10+ apps, this is the pattern I reach for every time. It's not perfect — Compose Navigation still has rough edges around dialog destinations and multi-module setups — but it handles the common cases cleanly.
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.
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