Stop Blaming Jetpack Compose: 5 Recomposition Anti-Patterns Ruining Your App
TIMESTAMP: Monday, April 13, 2026If you hang around Android developer forums long enough, you will inevitably see the same complaint: "Jetpack Compose is too slow. My lists are lagging. I'm going back to XML."
Let’s get one thing straight right now: Jetpack Compose is not slow. Your architecture is.
When we transitioned from the imperative world of XML to the declarative paradigm of Compose, many developers brought their old habits with them. In XML, updating a TextView was a single operation. In Compose, state drives the UI. If you handle state recklessly, you will trigger an avalanche of unnecessary UI redraws—known as recompositions—that will choke the main thread and kill your frame rate.
At xDroidDev, we build offline-first, high-performance engines like XD Video Player and XD Music. Dropped frames are not an option.
If your Compose app is stuttering, you are likely committing one of these five fatal anti-patterns. Here is how to fix them and restore structural code integrity to your UI.
1. The Unstable Class Trap (Lists & Data Classes)
Compose relies on a concept called "Stability" to decide if a UI component needs to be redrawn. If the data passed to a Composable hasn't changed, Compose skips redrawing it (this is called smart recomposition).
However, Compose treats the standard Kotlin List<T> as unstable by default. Why? Because under the hood, a List could be a MutableList, meaning its contents could change without Compose knowing. If you pass a standard List to a Composable, Compose panics and redraws the entire component every single time, just to be safe.
// THE BROKEN WAY
// Compose assumes this is unstable and will constantly recompose
data class LibraryState(
val tracks: List<Track>,
val isLoading: Boolean
)
@Composable
fun TrackList(state: LibraryState) { ... }
// XDROIDDEV OPTIMIZED
To fix this, you must explicitly promise the Compose compiler that the data will not change unexpectedly. Use the kotlinx.collections.immutable library.
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
// Now Compose knows this list is strictly immutable. Smart recomposition works.
data class LibraryState(
val tracks: ImmutableList<Track> = persistentListOf(),
val isLoading: Boolean = false
)
@Composable
fun TrackList(state: LibraryState) { ... }
(Alternatively, you can annotate your data class with @Immutable or @Stable, but using immutable collections is the mathematically proven route).
2. Reading State Too High in the Tree
Where you read a state determines what gets redrawn. If you read a rapidly changing state variable at the very top of your screen, the entire screen will recompose every time that value updates, even if only a tiny text label actually uses the data.
// THE BROKEN WAY
@Composable
fun PlayerScreen(viewModel: PlayerViewModel) {
// Reading the state at the root level!
val currentPosition = viewModel.playbackPosition.collectAsState().value
Column {
AlbumArt() // Redraws every millisecond!
TrackControls() // Redraws every millisecond!
// Only this actually needs the position
ProgressBar(position = currentPosition)
}
}
// XDROIDDEV OPTIMIZED
Defer the state read by passing a lambda () -> Int instead of the raw Int value. This pushes the state read down to the exact component that needs it, shielding the rest of the screen from useless redraws.
@Composable
fun PlayerScreen(viewModel: PlayerViewModel) {
// Collect the state, but DO NOT read the .value here
val positionState = viewModel.playbackPosition.collectAsState()
Column {
AlbumArt() // Safe. No recomposition.
TrackControls() // Safe. No recomposition.
// Pass a lambda. The read happens INSIDE the ProgressBar.
ProgressBar(positionProvider = { positionState.value })
}
}
3. The Scroll State Disaster
This is the #1 cause of lag in Compose applications. Let's say you want to hide a Floating Action Button (FAB) when the user scrolls down a LazyColumn.
If you directly read listState.firstVisibleItemIndex inside your composable, you are telling Compose: "Redraw this entire screen every single time the user scrolls by one pixel."
// THE BROKEN WAY
@Composable
fun EventListScreen() {
val listState = rememberLazyListState()
// DISASTER: This evaluates on every single scroll frame
val showFab = listState.firstVisibleItemIndex == 0
Scaffold(
floatingActionButton = { if (showFab) AddEventFab() }
) { ... }
}
// XDROIDDEV OPTIMIZED
You must isolate the rapid scroll calculations using derivedStateOf. This tells Compose to ignore the thousands of scroll pixel updates and only trigger a recomposition when the boolean result actually flips from true to false.
@Composable
fun EventListScreen() {
val listState = rememberLazyListState()
// Buffer the calculation. Recomposes ONLY when the boolean actually changes.
val showFab by remember {
derivedStateOf { listState.firstVisibleItemIndex == 0 }
}
Scaffold(
floatingActionButton = { if (showFab) AddEventFab() }
) { ... }
}
4. Inline Allocations in the Recomposition Loop
During an animation or a heavy list scroll, a Composable might recompose dozens of times per second. If you are creating new objects, allocating new lists, or sorting data directly inside the Composable function, you are flooding the garbage collector. Eventually, the GC has to pause your app to clean up your mess, resulting in a massive dropped frame (jank).
// THE BROKEN WAY
@Composable
fun Dashboard(events: List<Event>) {
// Sorting inside the Composable!
// This happens 60 times a second during an animation.
val sortedEvents = events.sortedBy { it.date }
LazyColumn { ... }
}
// XDROIDDEV OPTIMIZED
Heavy lifting belongs in the ViewModel or the data layer. The UI layer should be absolutely dumb. It should only render what it is given. If you absolutely must process data in the UI, wrap it in remember with the proper keys.
@Composable
fun Dashboard(events: List<Event>) {
// Only runs the sort when the 'events' list actually changes
val sortedEvents = remember(events) {
events.sortedBy { it.date }
}
LazyColumn { ... }
}
5. The remember Illusion
A common mistake is thinking remember makes your data invincible. It doesn't.
remember only caches a value across recompositions. If the user rotates their device, switches to dark mode, or if the Android system temporarily kills your app in the background, your remember block is wiped out completely.
// THE BROKEN WAY
@Composable
fun SettingsScreen() {
// This state vanishes if the device is rotated
var isOfflineMode by remember { mutableStateOf(false) }
Switch(checked = isOfflineMode, onCheckedChange = { isOfflineMode = it })
}
// XDROIDDEV OPTIMIZED
For UI state that must survive configuration changes (like a toggle switch, text input, or scroll position), you must use rememberSaveable. This serializes the data into the Android Bundle and safely restores it.
@Composable
fun SettingsScreen() {
// Survives rotation, dark mode toggles, and process death
var isOfflineMode by rememberSaveable { mutableStateOf(false) }
Switch(checked = isOfflineMode, onCheckedChange = { isOfflineMode = it })
}
The Bottom Line
Jetpack Compose gives you incredible power to build dynamic, responsive interfaces with a fraction of the code required by XML. But with that power comes the responsibility of understanding how the engine actually works.
Stop blaming the framework. Use the Compose Compiler Metrics to check your class stability. Use the Layout Inspector to track your recomposition counts. Defer your state reads, lock down your lists with immutability, and keep your UI layer dumb.
Engineering Android is about structural integrity. Write clean code, and your frames will follow.
