
Image from developer.android.com
Jetpack Compose gets rid of a lot of XML boilerplate but managing state between screens still trips up even experienced developers.
If you’ve ever:
- lost your UI state afterÂ
popBackStack(), - passed args viaÂ
NavBackStackEntry but gotÂnull at the worst time, - wondered when to useÂ
ViewModel vsÂCompositionLocal
You’re not alone.
This piece builds on my previous article, “Common pitfalls in Jetpack Compose navigation”, diving deeper into practical patterns for managing state across navigation flows.
Use ViewModel when the screen owns the state
If your screen performs API calls, holds UI state, or drives interactions, let a ViewModel manage it:
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val state by viewModel.uiState.collectAsState()
// Render screen
}
Get the ViewModel from your NavHost:
composable("profile/{userId}") {
val viewModel: ProfileViewModel = hiltViewModel()
ProfileScreen(viewModel)
}
Avoid setting viewModel: ProfileViewModel = hiltViewModel() as a default parameter. It ties your composable to Hilt implicitly and hinders previewing or reusing it.
Use SavedStateHandle for navigation arguments
If your screen depends on route arguments (userId, noteId), don’t parse them in the composable. Let your ViewModel extract them once:
@HiltViewModel
class ProfileViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val userId: String = checkNotNull(savedStateHandle["userId"])
}
This way, your screen becomes stateless and resilient to recomposition or configuration changes.
Use CompositionLocal for cross-cutting or session state
Need to share state like authentication, theme, or feature flags across many screens?
Use CompositionLocal, but with caution:
val localSession = staticCompositionLocalOf<Session> {
error("No session provided")
}
@Composable
fun AppContent(session: Session) {
CompositionLocalProvider(LocalSession provides session) {
NavHost(...) { /* screens here */ }
}
}
@Composable
fun HomeScreen() {
val session = LocalSession.current
Text("Hello, ${session.username}")
}
Don’t use CompositionLocal to bypass navigation scoping or pass ViewModels around. It’s for ambient context, not state lifting.
Keep state across navigation and configuration changes
While using ViewModel, SavedStateHandle, and CompositionLocal covers many state management scenarios in Compose navigation, you can further strengthen your architecture by ensuring state is preserved across configuration changes and back stack navigation.
Use rememberSaveable for transient UI state
Unlike remember, rememberSaveable persists state across configuration changes and process recreation. It’s a simple way to preserve user input and UI state without additional plumbing.
@Composable
fun SearchScreen() {
var query by rememberSaveable { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
label = { Text("Search") }
)
}
Use this when you need to keep user input, scroll positions, or simple selections across rotations or process death without adding ViewModel.
Use rememberNavBackStack (Navigation 3+) for the back stack state
If you’re using Navigation 3, rememberNavBackStack allows you to observe and manage your navigation back stack declaratively while preserving each screen’s state when navigating back. This is especially useful in bottom navigation or nested navigation graphs.
@Composable
fun AppNavigation() {
val navController = rememberNavController()
val backStack = rememberNavBackStack(navController)
Scaffold(
bottomBar = { BottomBar(navController, backStack) }
) { padding ->
NavHost(navController, startDestination = "home", Modifier.padding(padding)) {
composable("home") { HomeScreen() }
composable("settings") { SettingsScreen() }
}
}
}
This helps preserve state like scroll position or filters when switching between tabs or bottom navigation items.
Sharing state between screens
When multiple screens need to access shared state — for example, during a checkout process — hoist the ViewModel to a higher navigation level:
navigation(startDestination = "cart", route = "checkout") {
composable("cart") {
val viewModel: CheckoutViewModel = hiltViewModel(
rememberNavController().getBackStackEntry("checkout")
)
CartScreen(viewModel)
}
composable("payment") {
val viewModel: CheckoutViewModel = hiltViewModel(
rememberNavController().getBackStackEntry("checkout")
)
PaymentScreen(viewModel)
}
}
This ensures both screens share the same instance of CheckoutViewModel, keeping the cart data, shipping info, and in-progress transaction state consistent across the flow.
This pattern also works well for onboarding flows, profile editors with tabs, or any case where screens represent steps in a single business process.
Job Offers
Avoid these navigation-state pitfalls
Re-initializing ViewModels per screen
Avoid creating a new ViewModel inside every composable. If screens belong to the same flow, use navGraphViewModel (or hiltViewModel(navBackStackEntry = ...)) to share state.
Default ViewModel parameters in composables
Don’t use default parameters like viewModel: ProfileViewModel = hiltViewModel() inside your screen functions. This couples your composables to DI and breaks testing and previews. Inject the ViewModel from outside instead.
Overusing CompositionLocal as global state
CompositionLocal is powerful, but don’t treat it as a dumping ground for all shared state. Prefer ViewModels for managing screen or flow-level state with proper lifecycle awareness.
Conclusion
State management in Jetpack Compose navigation is less about picking the “right” tool and more about understanding scopes, lifecycles, and composition boundaries. Whether you’re working with ViewModel, SavedStateHandle, or CompositionLocal, each has its place — and its pitfalls.
Keep your navigation logic clean, your state ownership explicit, and your composables testable. By lifting state to the right level and resisting the urge to over-couple screens to DI or global context, you’ll end up with a navigation setup that’s both scalable and resilient.
Got your own pattern or pain point to share? Drop it in the comments — no edge case is too small to share.

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


