Jetpack Compose Patterns
State & Recomposition
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Count: $count", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { count++ }) { Text("Increment") }
}
}
// Hoist state for reusability
@Composable
fun CounterScreen(viewModel: CounterViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
CounterContent(count = state.count,
}
@Composable
fun CounterContent(count: Int, onIncrement: () -> Unit) {
// Pure, testable, no ViewModel dependency
Column {
Text("Count: $count")
Button(onClick = onIncrement) { Text("Increment") }
}
}
ViewModel Integration
data class UserUiState(
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class UserViewModel @Inject constructor(
private val repo: UserRepository,
savedState: SavedStateHandle
) : ViewModel() {
private val userId = savedState.get<Long>("userId")!!
private val _state = MutableStateFlow(UserUiState(isLoading = true))
val state: StateFlow<UserUiState> = _state.asStateFlow()
init {
loadUser()
}
private fun loadUser() = viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
runCatching { repo.findById(userId) }
.onSuccess { user -> _state.update { it.copy(user = user, isLoading = false) } }
.onFailure { e -> _state.update { it.copy(error = e.message, isLoading = false) } }
}
}
Side Effects
@Composable
fun SearchScreen(query: String) {
// LaunchedEffect — runs when key changes, cancels previous
LaunchedEffect(query) {
delay(300) // debounce
performSearch(query)
}
// DisposableEffect — cleanup on leave
val lifecycle = LocalLifecycleOwner.current
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) refresh()
}
lifecycle.lifecycle.addObserver(observer)
onDispose { lifecycle.lifecycle.removeObserver(observer) }
}
// rememberCoroutineScope — for event-driven launches (not initial load)
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { saveData() } }) { Text("Save") }
}
Navigation
@Composable
fun AppNavGraph(navController: NavHostController) {
NavHost(navController, startDestination = "home") {
composable("home") {
HomeScreen(onUserClick = { id -> navController.navigate("user/$id") })
}
composable(
"user/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.LongType })
) { backStack ->
val userId = backStack.arguments!!.getLong("userId")
UserDetailScreen(userId = userId,
}
}
}
Lists & Performance
@Composable
fun UserList(users: List<User>, onUserClick: (Long) -> Unit) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(users, key = { it.id }) { user -> // key prevents full recomposition
UserCard(user = user, onUserClick(user.id) })
}
}
}
// Stable class prevents unnecessary recomposition
@Stable
data class UserUiModel(val id: Long, val name: String, val avatarUrl: String)
Custom Modifiers & Theming
fun Modifier.shimmer(): Modifier = composed {
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0.2f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse)
)
alpha(alpha).background(MaterialTheme.colorScheme.surfaceVariant)
}
// Material 3 theming
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme,
typography = AppTypography,
content = content
)
Key Rules
- Hoist state up to the lowest common ancestor — composables that don't own state are easier to test and preview
- Use
key = { item.id } in LazyColumn items — prevents full list recomposition on data changes
LaunchedEffect(Unit) runs once on composition; LaunchedEffect(key) reruns when key changes
- Mark data classes as
@Stable or @Immutable when Compose can't infer stability — reduces unnecessary recomposition
- Never do I/O or heavy computation directly in a composable — always delegate to ViewModel or a coroutine