Understanding how Compose actually works under the hood separates developers who write correct Compose code from those who constantly fight recomposition bugs. Why does my composable recompose when nothing changed? Why doesn’t it recompose when I expect it to? Why does my state get lost? The answers lie in understanding the Composition lifecycle, the slot table, how Compose tracks state, and how smart recomposition decides what to skip. This guide gives you that mental model.
The Composition — What It Is
// The COMPOSITION is Compose's internal data structure
// It's a TREE that represents your current UI
// Think of it as Compose's equivalent of the View tree in XML
// When you call setContent { }, Compose:
// 1. Runs your composable functions (initial composition)
// 2. Builds a tree of UI nodes in the SLOT TABLE
// 3. Renders the tree to the screen
// The Composition has a LIFECYCLE:
//
// ┌─────────────────────────────────────────────────┐
// │ │
// │ ENTER Composition │
// │ (composable is called for the first time) │
// │ │ │
// │ ↓ │
// │ ACTIVE in Composition │
// │ (exists in the tree, may RECOMPOSE many times) │
// │ │ │
// │ ↓ │
// │ LEAVE Composition │
// │ (composable is no longer called — removed) │
// │ │
// └─────────────────────────────────────────────────┘
//
// "Enter" = composable appears in the tree (first call)
// "Recompose" = composable is re-called because its inputs changed
// "Leave" = composable disappears (if-branch changes, navigation, etc.)
// This lifecycle is DIFFERENT from Activity/Fragment lifecycle:
// - Activity lifecycle: onCreate → onResume → onPause → onDestroy
// - Composition lifecycle: Enter → Recompose (0..N times) → Leave
The Slot Table — Where State Lives
// The SLOT TABLE is the internal data structure where Compose stores:
// 1. The tree of composable calls (which composables are active)
// 2. State from remember { } calls
// 3. Effect instances (LaunchedEffect, DisposableEffect)
// 4. CompositionLocal values
// 5. Keys for positional memoization
// Think of it as an array of "slots" — one per composable call site:
//
// Slot Table:
// ┌─────────────┬───────────────────────────────────────────┐
// │ Position 0 │ MyScreen() — active │
// │ Position 1 │ remember { mutableStateOf(0) } → count │
// │ Position 2 │ Column() │
// │ Position 3 │ Text("Count: 0") │
// │ Position 4 │ Button() │
// │ Position 5 │ Text("Increment") │
// └─────────────┴───────────────────────────────────────────┘
//
// Each composable call has a POSITION in the slot table
// remember {} stores its value AT that position
// On recomposition, Compose walks the same positions to retrieve stored values
// This is why remember works:
// Initial composition: remember { mutableStateOf(0) } → creates state, stores at position 1
// Recomposition: remember { ... } → slot table already has a value at position 1 → returns it
// The block inside remember is NOT re-executed on recomposition
When Does Recomposition Happen?
// Recomposition is triggered when STATE that a composable READS changes
// Compose tracks reads automatically — you don't need to declare dependencies
@Composable
fun Example() {
var count by remember { mutableIntStateOf(0) }
var name by remember { mutableStateOf("Alice") }
Text("Count: $count") // READS count → recomposes when count changes
Text("Name: $name") // READS name → recomposes when name changes
Text("Static text") // reads NO state → never recomposes
Button(onClick = { count++ }) {
Text("Increment") // inside Button → recomposes when Button recomposes
}
}
// When count changes:
// - "Count: $count" → recomposes (reads count)
// - "Name: $name" → does NOT recompose (doesn't read count)
// - "Static text" → does NOT recompose (reads nothing)
// - Button → may or may not recompose (depends on Compose's analysis)
// KEY INSIGHT: Compose tracks STATE READS at the composable function level
// It doesn't recompose the entire tree — only functions that read changed state
The three phases of a Compose frame
// Every frame, Compose goes through three phases:
//
// ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
// │ COMPOSITION │ ──→ │ LAYOUT │ ──→ │ DRAWING │
// │ (what to │ │ (where to │ │ (how to │
// │ show) │ │ place it) │ │ render it) │
// └──────────────┘ └──────────────┘ └──────────────┘
//
// Phase 1: COMPOSITION
// - Runs composable functions
// - Determines WHAT the UI tree looks like
// - Creates/updates the slot table
// - State reads are tracked here
//
// Phase 2: LAYOUT
// - Measures each node (how big)
// - Places each node (where on screen)
// - Single-pass layout (not multi-pass like View system)
// - Modifier.onSizeChanged, Modifier.onGloballyPositioned read here
//
// Phase 3: DRAWING
// - Renders each node to the Canvas
// - Modifier.drawBehind, Modifier.drawWithContent read here
// - Canvas.drawXxx operations happen here
// You can DEFER state reads to later phases to avoid unnecessary recomposition:
// Reading state in Composition → recomposes the function
// Reading state in Layout → only re-layouts, no recomposition
// Reading state in Drawing → only redraws, no recomposition or re-layout
// Example: deferring scroll offset to drawing phase
val scrollOffset by remember { mutableFloatStateOf(0f) }
// ❌ Reading in Composition → recomposes on every scroll pixel
Text(modifier = Modifier.offset(y = scrollOffset.dp))
// ✅ Reading in Layout phase → skips composition, only re-layouts
Text(modifier = Modifier.offset { IntOffset(0, scrollOffset.toInt()) })
// The lambda version of offset reads state during LAYOUT, not Composition
Smart Recomposition — How Compose Skips Work
// Compose doesn't blindly recompose everything — it SKIPS composables
// whose inputs haven't changed. This is called SMART RECOMPOSITION.
// For a composable to be SKIPPABLE, ALL its parameters must be STABLE:
// - Compose compares old params vs new params
// - If ALL are equal → SKIP (don't re-call the function)
// - If ANY changed → RECOMPOSE (re-call the function)
@Composable
fun ArticleCard(
title: String, // String is stable ✅ (immutable)
isBookmarked: Boolean, // Boolean is stable ✅ (primitive)
onClick: () -> Unit // Function is stable ✅ (if it's the same lambda)
) {
// If title, isBookmarked, and onClick are the same as last time
// → this entire composable is SKIPPED (not re-called)
Card(onClick = onClick) {
Text(title)
Icon(if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = null)
}
}
// When parent recomposes but passes the SAME values:
// ArticleCard(title = "Same", isBookmarked = true, onClick = sameRef)
// → SKIPPED! Not re-called. No work done.
Stability — What Makes a Type Stable
Stability determines whether Compose can safely skip a composable. A type is stable if Compose can reliably compare two instances for equality:
// STABLE types — Compose can compare and skip:
// ✅ Primitives: Int, Long, Float, Double, Boolean, Char, Byte, Short
// ✅ String
// ✅ Enum classes
// ✅ @Immutable annotated classes
// ✅ @Stable annotated classes
// ✅ Data classes where ALL properties are stable
// ✅ Function types (lambda references)
// ✅ Compose state types (State<T>, MutableState<T>)
// UNSTABLE types — Compose CANNOT safely skip:
// ❌ List, Set, Map (mutable collections — could change without notification)
// ❌ Classes from external libraries (Compose can't verify their equals())
// ❌ Data classes with ANY unstable property
// ❌ Interfaces (implementations might be unstable)
// Why is List<String> unstable?
// Because List could be a MutableList — the contents could change
// without Compose knowing. Compose can't trust the equals() result.
data class Article(
val id: String, // ✅ stable
val title: String, // ✅ stable
val tags: List<String> // ❌ UNSTABLE — makes the whole class unstable!
)
// Because Article has an unstable property (tags: List),
// Compose treats the entire Article class as unstable
// → ArticleCard(article: Article) can NEVER be skipped!
Fixing stability with @Immutable and @Stable
// @Immutable — promise that this class will NEVER change after creation
// @Immutable is an ANNOTATION from compose.runtime
@Immutable
data class Article(
val id: String,
val title: String,
val tags: List<String> // you PROMISE this list won't be mutated
)
// Now Compose trusts that Article is stable → enables skipping
// @Stable — promise that changes to this class are OBSERVABLE by Compose
// @Stable is an ANNOTATION from compose.runtime
@Stable
class CounterState {
var count by mutableIntStateOf(0)
// count is backed by Compose state → changes ARE observable
// @Stable tells Compose: "trust me, you'll know when this changes"
}
// @Immutable vs @Stable:
// @Immutable → the object NEVER changes (all properties are val, no mutation)
// @Stable → the object MAY change, but changes are always observable by Compose
// @Immutable is stronger — use it when possible
// Using kotlinx.collections.immutable (recommended):
// implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class Article(
val id: String,
val title: String,
val tags: ImmutableList<String> // ✅ ImmutableList is @Stable
)
// ImmutableList is an INTERFACE from kotlinx.collections.immutable
// It's annotated @Stable — Compose treats it as stable
// No need for @Immutable on the data class — all properties are now stable
// Create immutable lists:
val tags = persistentListOf("Kotlin", "Android")
// persistentListOf() is a TOP-LEVEL FUNCTION that creates an ImmutableList
Recomposition Scope — What Gets Recomposed
// A RECOMPOSITION SCOPE is the smallest unit that Compose can recompose
// It's roughly equivalent to the nearest LAMBDA or COMPOSABLE FUNCTION
@Composable
fun ParentScreen() {
var count by remember { mutableIntStateOf(0) }
// Recomposition scope 1: ParentScreen itself
Text("Parent text") // recomposes when count changes (same scope)
// Recomposition scope 2: the lambda passed to Column
Column {
Text("Count: $count") // reads count → this scope recomposes
}
// Recomposition scope 3: ChildComposable
ChildComposable() // skipped if its inputs haven't changed
}
@Composable
fun ChildComposable() {
// This is a separate recomposition scope
// If ParentScreen recomposes but ChildComposable's inputs are the same
// → ChildComposable is SKIPPED
Text("I'm a child")
}
// Compose WRAPS each composable function call in a recomposition scope
// When state changes, only the SMALLEST scope that reads the state is recomposed
// This is why extracting composables into separate functions improves performance:
// ❌ Everything in one big function — entire function recomposes
@Composable
fun BigScreen() {
var count by remember { mutableIntStateOf(0) }
Text("Count: $count")
ExpensiveChart() // recomposes UNNECESSARILY when count changes
Text("Static footer") // recomposes UNNECESSARILY
}
// ✅ Extracted — each function is its own recomposition scope
@Composable
fun BigScreen() {
var count by remember { mutableIntStateOf(0) }
CountDisplay(count) // recomposes when count changes
ExpensiveChart() // SKIPPED (no inputs changed)
StaticFooter() // SKIPPED (no inputs)
}
@Composable
fun CountDisplay(count: Int) { Text("Count: $count") }
@Composable
fun StaticFooter() { Text("Static footer") }
Positional Memoization — How remember Knows Which Call
// Compose identifies each remember {} call by its POSITION in the call tree
// Not by a name or key — by WHERE it appears in the code
@Composable
fun Example() {
val a = remember { mutableStateOf("A") } // position 0
val b = remember { mutableStateOf("B") } // position 1
// On recomposition, Compose walks the same positions:
// Position 0 → retrieve stored value "A"
// Position 1 → retrieve stored value "B"
// Order matters! If the order changes, values get mixed up
}
// This is why you can't call remember inside conditions:
// ❌ remember in different positions depending on condition
@Composable
fun Broken(showExtra: Boolean) {
val a = remember { mutableStateOf("A") } // position 0 (always)
if (showExtra) {
val extra = remember { mutableStateOf("Extra") } // position 1 (sometimes)
}
val b = remember { mutableStateOf("B") } // position 1 or 2 (shifts!)
// When showExtra changes, "b" gets "Extra"'s stored value — BUG!
}
// ✅ In practice, the Compose compiler handles this correctly
// because it WRAPS conditional groups — the above actually works in Compose
// But LOOPING with remember needs keys:
// ❌ Same position for all loop iterations
@Composable
fun LoopBroken(items: List<String>) {
for (item in items) {
val state = remember { mutableStateOf(item) }
// All iterations share the SAME slot table position!
}
}
// ✅ Use key() to give each iteration a unique identity
@Composable
fun LoopFixed(items: List<String>) {
for (item in items) {
key(item) {
// key() is a COMPOSABLE INLINE FUNCTION from compose.runtime
// Creates a unique position in the slot table for each key value
val state = remember { mutableStateOf(item) }
Text(state.value)
}
}
}
CompositionLocal — Implicit Data Passing
// CompositionLocal lets you pass data IMPLICITLY through the Composition tree
// Without passing it explicitly as a parameter to every composable
// Built-in CompositionLocals:
val context = LocalContext.current
// LocalContext is a STATIC PROPERTY that returns a ProvidableCompositionLocal<Context>
// .current is a PROPERTY that reads the value from the nearest provider
val lifecycleOwner = LocalLifecycleOwner.current
// LocalLifecycleOwner is a COMPOSITION LOCAL for the current LifecycleOwner
val density = LocalDensity.current
// LocalDensity provides the current screen Density
val configuration = LocalConfiguration.current
// LocalConfiguration provides the current screen Configuration
// Creating your own CompositionLocal:
val LocalAnalytics = staticCompositionLocalOf<Analytics> {
error("No Analytics provided")
}
// staticCompositionLocalOf is a TOP-LEVEL FUNCTION from compose.runtime
// Creates a CompositionLocal that rarely changes (optimised)
// The lambda provides a DEFAULT value (or throws if none required)
// compositionLocalOf — for values that change frequently
val LocalThemeMode = compositionLocalOf { ThemeMode.System }
// compositionLocalOf is a TOP-LEVEL FUNCTION
// More expensive than static but handles frequent changes better
// Providing a value
@Composable
fun AppRoot() {
CompositionLocalProvider(
// CompositionLocalProvider is a COMPOSABLE FUNCTION
LocalAnalytics provides AnalyticsImpl()
// "provides" is an INFIX FUNCTION on ProvidableCompositionLocal
) {
// All composables inside can access LocalAnalytics.current
AppContent()
}
}
// Reading the value (anywhere in the subtree)
@Composable
fun SomeDeepComposable() {
val analytics = LocalAnalytics.current
LaunchedEffect(Unit) {
analytics.logScreenView("deep_screen")
}
}
Lifecycle Callbacks — Reacting to Enter/Leave
// Composables don't have onCreate/onDestroy
// But effects let you react to entering and leaving the Composition
// On ENTER — LaunchedEffect starts, DisposableEffect runs setup
LaunchedEffect(Unit) {
// Runs when composable ENTERS the Composition
println("Entered!")
}
DisposableEffect(Unit) {
println("Setup — entered Composition")
onDispose {
// Runs when composable LEAVES the Composition
println("Cleanup — left Composition")
}
}
// Tracking the Android lifecycle from Compose
@Composable
fun LifecycleAwareScreen() {
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> println("Screen visible")
Lifecycle.Event.ON_STOP -> println("Screen hidden")
Lifecycle.Event.ON_RESUME -> println("Screen interactive")
Lifecycle.Event.ON_PAUSE -> println("Screen losing focus")
else -> { }
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
Debugging Recomposition
// Technique 1: Recomposition counter
@Composable
fun RecompositionTracker(label: String) {
val recompCount = remember { mutableIntStateOf(0) }
SideEffect {
// SideEffect runs on EVERY successful recomposition
recompCount.intValue++
println("$label recomposed: ${recompCount.intValue} times")
}
}
// Usage — place inside any composable to track its recompositions
@Composable
fun ArticleCard(article: Article) {
RecompositionTracker("ArticleCard")
// ... card content ...
}
// Technique 2: Layout Inspector
// Android Studio → Tools → Layout Inspector
// Shows recomposition counts and skip counts per composable
// Yellow = recomposed, no highlight = skipped
// Technique 3: Compose Compiler Reports
// Add to build.gradle.kts:
// kotlinOptions {
// freeCompilerArgs += listOf(
// "-P",
// "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
// project.buildDir.absolutePath + "/compose_reports"
// )
// }
// Run: ./gradlew assembleRelease
// Check: app/build/compose_reports/
// Files show: which classes are stable, which composables are skippable/restartable
// Technique 4: Compose Compiler Metrics in Android Studio
// Build → Analyze APK → look for stability warnings
Common Mistakes to Avoid
Mistake 1: Passing unstable types causing excessive recomposition
// ❌ List<Article> is unstable → ArticleList can never be skipped
@Composable
fun ArticleList(articles: List<Article>) {
// Even if articles haven't changed, Compose can't verify → recomposes every time
}
// ✅ Use ImmutableList → Compose can compare and skip
@Composable
fun ArticleList(articles: ImmutableList<Article>) {
// ImmutableList is @Stable → Compose can safely skip if unchanged
}
// ✅ Or wrap in a stable wrapper
@Immutable
data class ArticleListState(val articles: List<Article>)
Mistake 2: Creating new lambdas on every recomposition
// ❌ New lambda instance every recomposition → child can't be skipped
@Composable
fun Parent() {
val viewModel: MyViewModel = hiltViewModel()
ArticleList(
onArticleClick = { id -> viewModel.onArticleClicked(id) }
// New lambda object every recomposition!
// ArticleList sees a "different" onClick → recomposes
)
}
// ✅ Use method reference — same reference across recompositions
ArticleList(onArticleClick = viewModel::onArticleClicked)
// :: creates a FUNCTION REFERENCE — stable, same instance
// ✅ Or use remember for complex lambdas
val onArticleClick = remember(viewModel) {
{ id: String -> viewModel.onArticleClicked(id) }
}
Mistake 3: Reading state too broadly
// ❌ Entire screen reads scroll state → entire screen recomposes on scroll
@Composable
fun Screen() {
val scrollState = rememberScrollState()
val scrollY = scrollState.value // READ in Composition phase!
Column(modifier = Modifier.verticalScroll(scrollState)) {
Header(alpha = scrollY / 500f) // this is fine
ExpensiveContent() // recomposes on EVERY scroll pixel!
}
}
// ✅ Isolate state reads to the composable that needs them
@Composable
fun Screen() {
val scrollState = rememberScrollState()
Column(modifier = Modifier.verticalScroll(scrollState)) {
AnimatedHeader(scrollState) // only this recomposes
ExpensiveContent() // SKIPPED — doesn't read scrollState
}
}
@Composable
fun AnimatedHeader(scrollState: ScrollState) {
val alpha = scrollState.value / 500f // state read isolated here
Text("Header", modifier = Modifier.alpha(alpha))
}
Mistake 4: Assuming recomposition order
// ❌ Don't assume composables execute in order or exactly once
@Composable
fun Screen() {
var sideEffectCount = 0
Text("A")
sideEffectCount++ // ❌ might run multiple times, in any order
Text("B")
println("Side effects: $sideEffectCount") // unpredictable!
}
// ✅ Use proper effect handlers for side effects
@Composable
fun Screen() {
LaunchedEffect(Unit) {
println("Runs exactly once, controlled")
}
SideEffect {
println("Runs after every successful recomposition, controlled")
}
}
Mistake 5: Not extracting composables for better skipping
// ❌ One big composable — everything recomposes when count changes
@Composable
fun BigScreen() {
var count by remember { mutableIntStateOf(0) }
Text("Count: $count")
// Heavy content below also recomposes even though it doesn't use count
HeavyChart(data = chartData)
Image(painter = painterResource(R.drawable.big_image), contentDescription = null)
}
// ✅ Extract into separate composables — independent recomposition scopes
@Composable
fun BigScreen() {
var count by remember { mutableIntStateOf(0) }
CountDisplay(count = count) // recomposes when count changes
HeavyChart(data = chartData) // SKIPPED (stable params unchanged)
BackgroundImage() // SKIPPED (no params)
}
@Composable fun CountDisplay(count: Int) { Text("Count: $count") }
@Composable fun BackgroundImage() {
Image(painter = painterResource(R.drawable.big_image), contentDescription = null)
}
Summary
- The Composition is Compose’s internal tree structure — composables enter, recompose (0..N times), and leave
- The slot table stores the tree of composable calls,
remembervalues, effects, and keys - Recomposition is triggered when State that a composable reads changes — Compose tracks reads automatically
- Three frame phases: Composition (what) → Layout (where) → Drawing (how) — defer state reads to later phases for performance
- Smart recomposition skips composables whose inputs haven’t changed — requires stable parameters
- Stable types: primitives, String, enums,
@Immutableclasses,@Stableclasses,ImmutableList - Unstable types:
List,Map,Set(mutable collections), classes with unstable properties, interfaces - Fix stability with
@Immutable(annotation),@Stable(annotation), orImmutableListfrom kotlinx-collections-immutable - Recomposition scope is the smallest unit Compose can recompose — extracting composables creates independent scopes
- Positional memoization —
rememberstores values by call position; usekey()(composable inline function) in loops - CompositionLocal passes data implicitly through the tree —
staticCompositionLocalOf(top-level function) for rarely changing values,compositionLocalOffor frequent changes - Use function references (
viewModel::method) instead of lambdas to avoid creating new instances on every recomposition - Debug with recomposition counter, Layout Inspector, and Compose Compiler Reports
Understanding the Composition lifecycle and recomposition is what makes you effective with Compose. Know when recomposition happens (state reads), know what gets skipped (stable parameters), know how to isolate state reads (extract composables, defer to layout/draw phase), and know how to debug (compiler reports, Layout Inspector). This mental model turns mysterious recomposition bugs into predictable, solvable problems.
Happy coding!
Comments (0)