State, Lists and Navigation in Compose

remember and mutableStateOf

mutableStateOf creates observable state: when it changes, composables that read it recompose. remember keeps it across recompositions; rememberSaveable also survives rotation.

@Composable
fun QuantityStepper(modifier: Modifier = Modifier) {
    var qty by rememberSaveable { mutableIntStateOf(1) }
    Row(modifier, verticalAlignment = Alignment.CenterVertically) {
        IconButton(onClick = { if (qty > 1) qty-- }) { Icon(Icons.Default.Remove, contentDescription = "Less") }
        Text("$qty", style = MaterialTheme.typography.titleMedium)
        IconButton(onClick = { qty++ }) { Icon(Icons.Default.Add, contentDescription = "More") }
    }
}

@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {     // hoisted state
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        label = { Text("Search products") },
        singleLine = true,
        modifier = Modifier.fillMaxWidth(),
    )
}

LazyColumn and Screen States

@Composable
fun ProductsScreen(state: ProductsUiState, onRetry: () -> Unit, onAdd: (Product) -> Unit) {
    when (state) {
        ProductsUiState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
            CircularProgressIndicator()
        }
        is ProductsUiState.Error -> Column(
            Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Text(state.message)
            Button(onClick = onRetry) { Text("Retry") }
        }
        is ProductsUiState.Success -> LazyColumn(
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            items(state.products, key = { it.id }) { product ->
                ProductCard(product, onAdd = { onAdd(product) })
            }
        }
    }
}

Tip: Always give items a stable key (the database id). Compose then keeps scroll position and animations correct when the list changes.

Type-safe Navigation

Navigation Compose 2.8+ uses @Serializable classes as routes, so arguments are type-checked.

@Serializable object ProductList
@Serializable data class ProductDetail(val productId: Long)

@Composable
fun ShopNavHost(modifier: Modifier = Modifier, navController: NavHostController = rememberNavController()) {
    NavHost(navController = navController, startDestination = ProductList, modifier = modifier) {
        composable<ProductList> {
            ProductsRoute(onOpen = { id -> navController.navigate(ProductDetail(id)) })
        }
        composable<ProductDetail> { backStackEntry ->
            val args = backStackEntry.toRoute<ProductDetail>()
            ProductDetailRoute(productId = args.productId, onBack = { navController.popBackStack() })
        }
    }
}

Side effects that must run when a composable appears use LaunchedEffect; it runs in a coroutine and is cancelled when the composable leaves:

LaunchedEffect(productId) { viewModel.load(productId) }   // re-runs if productId changes

val scope = rememberCoroutineScope()
val snackbar = remember { SnackbarHostState() }
Button(onClick = { scope.launch { snackbar.showSnackbar("Added to cart") } }) { Text("Add") }