Compose has built-in, declarative animation APIs:

@Composable
fun ExpandableCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }
    val rotation by animateFloatAsState(
        targetValue = if (expanded) 180f else 0f,
        label = "chevron-rotation"
    )

    Card(modifier = Modifier.clickable { expanded = !expanded }) {
        Column(Modifier.padding(16.dp)) {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(title, modifier = Modifier.weight(1f))
                Icon(
                    Icons.Default.ExpandMore,
                    contentDescription = null,
                    modifier = Modifier.rotate(rotation)
                )
            }
            AnimatedVisibility(visible = expanded) {
                Text(body, modifier = Modifier.padding(top = 8.dp))
            }
        }
    }
}

Side effects: LaunchedEffect

Composable functions must be free of side effects (like starting a coroutine directly) since they can recompose many times. LaunchedEffect safely runs suspend code tied to a composable’s lifecycle:

@Composable
fun MovieDetailScreen(movieId: Int, viewModel: MovieDetailViewModel = hiltViewModel()) {
    LaunchedEffect(movieId) {
        viewModel.loadMovie(movieId)   // re-runs only if movieId changes
    }
    // ... render UI from viewModel's state
}

The movieId key parameter matters: LaunchedEffect only re-launches its block when the key changes, not on every recomposition — essential for avoiding redundant network calls.