Scale Compose Nav with Nested Graphs and State Layers

For apps with 20-50 screens, use one root NavHost with nested feature graphs, centralized route objects, and layered state (nav args for IDs, ViewModels for data, composables for UI) to prevent navigation fragility.

Nested Graphs Isolate Features and Simplify Reasoning

Flat NavHosts with dozens of composables become unmaintainable—files bloat, flows interfere, back stacks confuse, and refactors risk breakage. Instead, centralize ownership in one root NavHost that only defines major app flows (e.g., auth, main), then nest separate graphs per feature.

Root setup:

@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = Graph.AUTH,
        route = Graph.ROOT
    ) {
        authNavGraph(navController)
        mainNavGraph(navController)
    }
}

Feature graphs group related screens:

fun NavGraphBuilder.authNavGraph(navController: NavHostController) {
    navigation(startDestination = "login", route = Graph.AUTH) {
        composable("login") {
            LoginScreen(
                onLoginSuccess = {
                    navController.navigate(Graph.MAIN) {
                        popUpTo(Graph.AUTH) { inclusive = true }
                    }
                }
            )
        }
        // signup composable...
    }
}

This keeps graphs small, prevents cross-flow pollution, and makes back stack behavior predictable—e.g., clear auth stack on login success.

Centralize routes with sealed classes to avoid string typos:

sealed class AppScreen(val route: String) {
    data object Home : AppScreen("home")
    data object UserDetails : AppScreen("user/{userId}") {
        fun createRoute(userId: Long) = "user/$userId"
    }
}

Layered State Keeps Navigation Stable

Navigation only handles movement and small primitives (IDs, filters, flags)—never full objects, which fail serialization and stale quickly. Load data in destinations.

Three layers:

  1. Nav args: Primitives only, e.g., navController.navigate(UserDetails.createRoute(42L)).
  2. ViewModels: Screen/business state with API calls, loading/errors. Extract args via SavedStateHandle:
class UserDetailsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    private val userId: Long = checkNotNull(savedStateHandle["userId"])
    val uiState = MutableStateFlow(UserDetailsUiState())
    // Load data in init...
}
  1. Composable state: UI-only like rememberSaveable { mutableStateOf("") } for inputs/toggles.

This separation makes nav robust: pass ID, fetch fresh data, avoid fragile deep passing.

Route-Level Wiring and Tab Polish

Avoid scattering NavController in leaf UI—pass lambdas from route composables for reusable, testable screens:

@Composable
fun HomeRoute(navController: NavHostController) {
    HomeScreen(
        onOpenSettings = { navController.navigate("settings") },
        onOpenUser = { id -> navController.navigate("user/$id") }
    )
}
@Composable
fun HomeScreen(onOpenSettings: () -> Unit, onOpenUser: (Long) -> Unit) {
    // Buttons call lambdas
}

For bottom tabs, use this nav spec to avoid duplicates, save/restore state:

navController.navigate(route) {
    popUpTo(navController.graph.startDestinationId) { saveState = true }
    launchSingleTop = true
    restoreState = true
}

This preserves tab state across switches, polishing UX.

Full Stack Ties It Together

Thin MainActivity sets App() with rememberNavController(). AppNavHost wires root. Graphs use route sealed interfaces (e.g., AuthDest.Login.route). UserDetails handles args:

composable(
    route = MainDest.UserDetails.route,
    arguments = listOf(navArgument(MainDest.UserDetails.ARG) { type = NavType.LongType })
) { entry ->
    val userId = entry.arguments?.getLong(MainDest.UserDetails.ARG) ?: return@composable
    UserDetailsRoute(userId)
}

UserDetailsRoute injects viewModel(), collects uiState, passes to pure UserDetailsScreen. Add screens freely without central chaos.

Summarized by x-ai/grok-4.1-fast via openrouter

6768 input / 1765 output tokens in 14725ms

© 2026 Edge