
photo by kellymarken on istockphoto
Jetpack Compose promises a more declarative and flexible way to build UI in Android — and it delivers. But when it comes to navigation, many developers find themselves running into subtle (and not so subtle) issues that aren’t always obvious at first glance.
In this article, I’ll share some of the most common pitfalls I’ve encountered while working with Navigation-Compose — why they occur and how to avoid them based on real-world experience.
1. Accidentally creating multiple NavControllers
The problem: You declare rememberNavController()
in multiple composables, unintentionally creating separate navigation stacks.
@Composable fun HomeScreen() { val navController = rememberNavController() // Not the shared one //... }
Why it happens: Each call to rememberNavController()
creates a new instance. If you’re not passing a single NavController
down from the root, you’ll lose your navigation state.
How to avoid it: Declare rememberNavController()
once at the top level and pass it down to all children.
@Composable fun AppRoot() { val navController = rememberNavController() NavHost(navController = navController, startDestination = "home") { // ... } }
2. Navigating from the wrong lifecycle scope
The problem: You trigger navController.navigate(...)
inside LaunchedEffect
, rememberCoroutineScope
, or other unstable contexts.
Why it happens: Navigation needs to be called from a stable, active composition. Doing it from effects tied to recomposition or disposables can lead to crashes or ignored events.
How to avoid it: Use LaunchedEffect(key1 = true)
only after composition is committed. Ensure you’re not launching navigate calls from unstable or invalid scopes.
LaunchedEffect(Unit) { navController.navigate("details") }
Or better: use event-based ViewModel communication.
3. Misusing NavBackStackEntry or arguments
The problem: Accessing navBackStackEntry.arguments
directly and assuming it’s always valid.
Why it happens: Navigation arguments are tied to back stack entries and may not exist in all contexts. Nulls and type mismatches lead to crashes.
How to avoid it: Use navArgument()
with type-safe defaults or consider using a navigation helper like hiltViewModel(backStackEntry)
where supported.
composable("profile/{userId}", arguments = listOf(navArgument("userId") { type = NavType.StringType })) { val userId = it.arguments?.getString("userId") ?: return@composable }
4. Forgetting popUpTo + inclusive in backstack control
The problem: Screens keep piling up in the back stack, and back navigation doesn’t behave as expected.
Why it happens: By default, each navigation adds a destination to the back stack. Without popUpTo
, the stack grows indefinitely.
How to avoid it: Explicitly configure popUpTo
and inclusive = true
when replacing screens (e.g., after login or onboarding).
navController.navigate("home") { popUpTo("login") { inclusive = true } }
5. Using hardcoded strings instead of Type-Safe routes
The problem: You navigate using string literals like "details/123"
scattered across your code.
Why it happens: Compose Navigation doesn’t enforce type safety by default.
How to avoid it: Use a sealed class or object-based route system to define destinations and build routes safely.
sealed class Screen(val route: String) { object Home : Screen("home") data class Details(val id: Int) : Screen("details/{id}") { fun createRoute(id: Int) = "details/$id" } }
6. Forgetting navigation preview limitations
The problem: Previews fail or crash when they try to render composables using NavController
.
Why it happens: NavController
is not available in preview mode.
How to avoid it: Use parameter injection with default fallbacks or overload functions for preview purposes.
@Composable fun SignUpScreen(navController: NavController? = null) { // Use navController?.navigate(...) safely }
7. Overusing Single-Activity architecture without boundaries
The problem: Embracing the single-activity model but putting all screens, navigation logic, and UI in one place — leading to a bloated NavHost
, tightly coupled features, and poor testability.
Why it happens: Compose makes it easy to build everything in one activity with a single NavController
, but this simplicity can mask architectural complexity as the app scales.
How to avoid it: Break down your navigation graph into nested graphs or modules. Encapsulate navigation logic inside feature-specific Navigators or use dependency injection to delegate navigation calls.
// Delegate navigation to a feature module class ProfileNavigator(private val navController: NavController) { fun openEditProfile(userId: String) { navController.navigate("editProfile/$userId") } }
Job Offers
Conclusion
Navigation in Jetpack Compose isn’t inherently broken, but it’s easy to misuse. Many of the APIs assume you’re aware of lifecycle, composition scope, and how back stacks work under the hood.
By recognizing these pitfalls early and applying consistent architectural patterns, your navigation code can become predictable, testable, and resilient.
Got a navigation quirk or workaround worth sharing? Drop it in the comments — let’s build better Compose apps together.

Hands-on insights into mobile development, architecture, and team leadership.
📬 Follow me on Medium
This article was previously published on proandroiddev.com.