Jetpack Compose Best Practices I Actually Use
Jetpack Compose Best Practices I Actually Use
When Jetpack Compose hit stable, it changed how I think about Android UI. No more XML layouts, no more findViewById, no more juggling view binding. Instead, you describe what the screen should look like and Compose handles the rest. But "declarative UI" is just the starting point. After shipping several production apps with Compose, I've landed on a set of practices that keep my code maintainable, performant, and testable.
Here's what actually works for me.
State Management: Get This Right First
State is the core of every Compose UI. Get it wrong and you'll chase recomposition bugs for hours. Get it right and everything flows naturally.
remember vs rememberSaveable
Use remember for transient UI state that can be lost on configuration changes. Use rememberSaveable for anything the user would be frustrated to lose -- like text field input or scroll position.
// Transient state: fine to lose on rotation
var isExpanded by remember { mutableStateOf(false) }
// User-entered data: preserve across config changes
var searchQuery by rememberSaveable { mutableStateOf("") }I default to rememberSaveable unless I have a clear reason not to. The small overhead is worth avoiding the "I typed something and it disappeared" bug.
State Hoisting
The single most impactful pattern in Compose is state hoisting. Push state up, push events down. A composable that owns no state is a composable you can reuse, preview, and test without any ceremony.
// Stateless: easy to test, reuse, and preview
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSearch: () -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier.fillMaxWidth(),
placeholder = { Text("Search...") },
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
singleLine = true
)
}
// Stateful wrapper for convenience
@Composable
fun SearchBarStateful(
onSearch: (String) -> Unit,
modifier: Modifier = Modifier
) {
var query by rememberSaveable { mutableStateOf("") }
SearchBar(
query = query,
onQueryChange = { query = it },
onSearch = { onSearch(query) },
modifier = modifier
)
}I build the stateless version first, then wrap it only where needed. This gives me full control from ViewModels and keeps the composable library-grade reusable.
Reusable Component Design
Slot APIs
Compose's slot API pattern lets callers inject their own content into predefined "slots." This is far more flexible than parameter overloads.
@Composable
fun SectionCard(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
title()
Row(content = actions)
}
Spacer(modifier = Modifier.height(12.dp))
content()
}
}
}The caller decides what goes in each slot. No need for ten different parameters for title styles, icon variants, or action types.
The Modifier Convention
Every public composable should accept a Modifier parameter as the first optional parameter with a default of Modifier. This is non-negotiable in my codebase.
// Follow this pattern consistently
@Composable
fun ProfileAvatar(
imageUrl: String,
modifier: Modifier = Modifier // Always present, always defaulted
) {
AsyncImage(
model = imageUrl,
contentDescription = "Profile photo",
modifier = modifier
.size(48.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
}The caller can then chain layout modifiers naturally: ProfileAvatar(url, Modifier.padding(8.dp)). Without this convention, composables become impossible to lay out flexibly.
Performance: Measure Before You Optimize
Compose is fast by default. Most performance "tips" floating around are premature optimization. That said, there are a few patterns I reach for when profiling reveals actual issues.
derivedStateOf
When you derive a value from state that changes frequently, wrap the derivation in derivedStateOf to avoid unnecessary recompositions.
val listState = rememberLazyListState()
// Without derivedStateOf: recomposes on every scroll pixel
// val showButton = listState.firstVisibleItemIndex > 0
// With derivedStateOf: recomposes only when the boolean changes
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}This matters for scroll listeners and other high-frequency state changes. For simple derived values that don't change often, it's unnecessary overhead.
LazyColumn Keys
Always provide stable keys in LazyColumn and LazyRow. Without keys, Compose can't tell which items moved, leading to unnecessary recompositions and broken animations.
LazyColumn {
items(
items = tasks,
key = { task -> task.id } // Stable, unique identifier
) { task ->
TaskRow(task = task)
}
}I've seen apps skip this and wonder why their list animations stutter. Keys are cheap insurance.
@Stable and @Immutable
Marking your data classes tells the Compose compiler they won't change unexpectedly, enabling it to skip recompositions more aggressively.
@Immutable
data class ChartData(
val label: String,
val values: List<Float>
)
@Stable
class ThemeConfig(
val primaryColor: Color,
val isDarkMode: Boolean
)Use @Immutable for truly immutable data classes. Use @Stable for objects where changes are always reported through snapshot state. Don't slap these on everything -- the compiler is already smart about standard Kotlin data classes.
Testing Compose UIs
Stateless composables are trivially testable. That's one more reason to hoist state aggressively.
@Test
fun searchBar_displaysQueryAndTriggersCallback() {
var capturedQuery = ""
composeTestRule.setContent {
SearchBar(
query = "kotlin",
onQueryChange = {},
onSearch = { capturedQuery = "kotlin" }
)
}
composeTestRule
.onNodeWithText("kotlin")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("kotlin")
.performImeAction()
assertEquals("kotlin", capturedQuery)
}Because SearchBar is stateless, I don't need fake ViewModels or dependency injection in the test. I pass values in, assert on the output. That's it.
For screen-level tests, I pair Compose test rules with fake repositories so I can verify the full integration without hitting real APIs.
What I Keep Coming Back To
The common thread across all of these practices is the same: keep composables small, stateless, and focused on a single job. State hoisting, slot APIs, the Modifier convention, and performance annotations all push you toward the same goal -- components that are easy to understand, reuse, and test.
Compose gives you the tools. The discipline is in using them consistently across a real codebase, not just in sample projects. That's where these practices have made the biggest difference for me.
Share this article