Android Jetpack Compose Recomposition Explained: From Beginner to Pro

Android Jetpack Compose Recomposition Explained: From Beginner to Pro

A comprehensive mobile systems developer's guide to declarative UI compilation — covering recomposition triggers, remember/mutableStateOf storage, derivedStateOf, side effects, and compiler stability.

For over a decade, Android UI development relied on an imperative model. You defined layouts in XML, and mutated them at runtime by querying the view hierarchy via findViewById and calling setters like setText() or setVisibility(). This approach is prone to state synchronization bugs. Jetpack Compose resolves this by introducing a **Declarative UI Paradigm**.

In Jetpack Compose, UI components are declared as lightweight, stateless functions that compile down to UI node tree layouts. When application data changes, the Compose runtime does not mutate the existing UI components. Instead, it re-runs the composable functions with the updated inputs—a process known as **Recomposition**. Understanding how recomposition operates is vital for building smooth, 60fps applications and preventing memory leaks. This guide walks you through recomposition triggers, state storage, side-effect APIs, and compiler stability optimizations.


1. The Imperative vs Declarative UI Paradigm

1.1 State Synchronization Complexity

In traditional Android XML layouts, the UI has its own internal state. If a network call updates a user profile name, you must write code to update the TextView. As the app grows, you must sync this name across multiple screens: dashboards, headers, settings. If you forget to update even one TextView, the UI gets out of sync, displaying corrupted or legacy data. The developer is responsible for writing the mutation paths.

Declarative UI eliminates this complexity. Composable functions are written as pure projections of the application state. When the state updates, the framework automatically handles updating the UI elements that depend on that state. The developer only manages the data state; the framework manages the rendering paths.

1.2 Imperative Mutations vs Declarative Projections

An imperative layout requires active traversal of the view hierarchy to push mutations. A declarative layout uses compiler-generated slots to update changed parameters on the fly, saving CPU cycles.

Common Misconception — Declarative UI rebuilds the entire screen: A common developer misconception is that declarative UI rebuilds and redraws the entire screen from scratch whenever any state changes. In reality, the Compose compiler parses composable functions and registers dependencies at compile-time. When a variable updates, only the specific composable functions that read that variable are re-executed; unaffected sibling UI nodes are skipped, preserving performance.


2. What is Recomposition?

2.1 The Re-Execution Loop

Recomposition is the process of re-executing composable functions when their underlying inputs change. During the initial compilation, the Compose compiler inserts structural metadata into the function frames, mapping inputs to outputs. The compiler registers a dependency between the composable and the variables it reads. When a variable changes, the Compose runtime schedules a recomposition pass for that block.

If the inputs are identical to the previous run, the runtime skips executing the function body, serving the previously constructed layout nodes instead. This selective execution allows Compose to achieve near-instantaneous rendering updates.

graph TD Parent["Parent Composable"] ChildA["Child A (Unchanged)"] ChildB["Child B (State Changed)"] Parent --> ChildA Parent --> ChildB Parent -.->|"Skip Execution"| ChildA Parent -.->|"Trigger Recomposition"| ChildB

Mermaid Diagram: The Composition tree showing selective recomposition execution, where Child A is skipped while Child B re-executes.


3. Managing State: mutableStateOf and remember

3.1 Storing State Across Executions

To trigger recomposition, Compose must track variables. This is done by wrapping data in the MutableState<T> interface via mutableStateOf(value). When a composable reads the value property of a MutableState object, the runtime registers a subscription. When the value is reassigned, all subscribing composables are flagged for recomposition.

However, because recomposable functions run repeatedly, writing val state = mutableStateOf(0) directly inside a function will reset the state back to 0 on every recomposition. To prevent this, we wrap the allocation inside the remember block (e.g. val state = remember { mutableStateOf(0) }). The remember block tells the runtime: "Store this object in the Composition's internal slot table during the initial run, and return the cached instance on all subsequent recompositions."


4. The Composable Lifecycle: Enter, Recompose, and Exit

4.1 Composition Lifecycle Stages

A Composable node's lifespan is defined by three phases within the Composition tree:

  • **Enter**: The composable is executed for the first time and inserted into the Composition structure.
  • **Recompose**: The composable executes one or more times in response to state updates, refreshing its children.
  • **Exit**: The composable is removed from the screen, freeing its cached variables from the Composition slot tables.

5. Optimization: derivedStateOf and remember(key)

5.1 Mitigating Redundant Recompositions

A common cause of performance degradation in Compose applications is high-frequency recompositions. For example, if you observe a scroll state (like list offset pixels) to show or hide a "Scroll to Top" button, the offset updates on every pixel. A composable reading this offset directly will recompose 60 times a second, introducing layout lag.

To prevent this, we use derivedStateOf. The derivedStateOf function calculates a state from another state, but only triggers recompositions when the final output changes (e.g. converting pixel offset to a boolean flag: derivedStateOf { offset > 100 }). Recompositions are reduced to only when the threshold is crossed, saving CPU cycles.

Pitfall — Forgetting key parameters in remember: If your remember block calculates a value based on a function argument but you omit that argument from the remember keys (e.g. using remember { format(date) } instead of remember(date) { format(date) }), the value will never update, even if the date argument changes. Always specify dependent variables as keys in the remember block.


6. Side Effects in Compose: LaunchedEffect and DisposableEffect

6.1 Safe Async Operations

Because composable functions run on the main thread and can be executed at any time (or even cancelled midway), performing operations like network requests, database writes, or animation triggers directly inside the function body is unsafe. These are called **Side Effects**.

Compose provides dedicated APIs to run side effects safely: (1) LaunchedEffect(key): starts a coroutine scope when the composable enters the composition. If the key changes, the current coroutine is cancelled and a new one is launched. (2) DisposableEffect(key): used for operations that require cleanup (like registering listeners). It exposes an onDispose block that runs when the node exits the composition, preventing memory leaks.

6.2 Side Effect Scopes and Coroutine Cancellation

In a custom declarative UI, side effects must be scoped directly to the Composable lifecycle. When LaunchedEffect(key) is executed, the Compose runtime binds a standard CoroutineContext containing a Job to the Composable's node in the slot table. This context runs on the Android main thread dispatcher by default. The execution steps flow sequentially:

  • **Initialization**: When the node enters the Composition, the runtime launches the coroutine block.
  • **Key Variation**: If a parent recomposes and passes a different value for key, the runtime cancels the active Job via a CancellationException and launches a fresh coroutine.
  • **Termination**: When the node exits the Composition (e.g. the user navigates away), the coroutine is cancelled automatically.

For callbacks that occur in non-composable scopes (like button click events), we cannot call LaunchedEffect directly. Instead, we use rememberCoroutineScope(). This API returns a reference to a CoroutineScope bound to the Composable's lifecycle position, allowing us to safely spawn jobs. Below is a comparison table of side-effect APIs:

Side-Effect API Execution Trigger Lifecycle Scope Cleanup Hook
LaunchedEffect(key) On entry & key changes Main Thread Coroutine Automatic Job cancellation
DisposableEffect(key) On entry & key changes Synchronous block Explicit onDispose { } block execution
rememberCoroutineScope() Returns scope once Manual launch bounds Automatic cancellation on exit

Pitfall — Leaking Listeners in Side Effects: Using a standard callback listener inside a LaunchedEffect without a cleanup hook will leak the listener reference if the coroutine is cancelled. For registering listeners, observers, or receiver hooks, always prefer DisposableEffect and deregister the listener inside the onDispose block.


7. Advanced: Compose Compiler Stability Annotations (@Stable and @Immutable)

7.1 Skippable vs Non-Skippable Composables

To skip executing a composable during recomposition, the Compose compiler must be certain that the function arguments are stable (i.e. their properties will not change without triggering a state update). The compiler analyzes all parameters and classifies them as stable or unstable. Core classes (like primitives, String) are stable. Standard Kotlin collection classes (like List, Map) are classified as **unstable** because their contents can mutate (e.g. casting a List to a MutableList).

If a composable accepts even one unstable parameter, the compiler classifies the function as **non-skippable**. This means that whenever the parent recomposes, this child is forced to recompose, regardless of whether the collection has changed. To resolve this, developers use stability annotations: @Immutable or @Stable. This tells the compiler: "Trust that this class's properties will not mutate," allowing the compiler to optimize the rendering pass.

7.2 Stability Contracts and Compiler Metrics Analysis

To understand stability, we look at the contract defined by the Compose runtime. A type $T$ is considered stable if it satisfies the following two properties:

  • **Referential Equality**: The result of equals comparisons for two instances of $T$ will remain constant over time.
  • **Observable Changes**: If any property of $T$ changes, the Compose runtime is notified via a state observation mechanism.

Formally, we define a stability contract value $S(c)$ for class $c$ as:

$$ S(c) = \begin{cases} \text{Stable} & \text{if all fields of } c \text{ are stable and declared as val} \\ \text{Unstable} & \text{if any field of } c \text{ is var, or is a generic interface collection} \end{cases} $$

During compilation, developers can generate stability reports by configuring compiler metrics flags in Gradle:

freeCompilerArgs += listOf(
    "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=/path/to/reports"
)

The compiler outputs class files analyzing stability status. Below is a sample from the generated reports file:

unstable class UserState {
    stable val id: String
    unstable val items: List<String> // generic interface collections default to unstable
}

To make this class stable, we can wrap the list in an immutable class wrapper annotated with @Immutable, or use Kotlin's kotlinx-collections library, telling the compiler it is safe to skip:

@Immutable
data class UserState(
    val id: String,
    val items: ImmutableList<String>
)

Below is a comparison table of parameters stability classifications:

Type Category Stability Status Recomposition Impact Recommended Wrapper API
Primitive Types (Int, String) Stable (default) Skippable (skipped if values are identical) None (built-in stability)
Generic Collections (List, Set) Unstable (default) Recomposed (forces execution on every parent update) ImmutableList / kotlinx-collections
Mutable State (MutableState<T>) Stable (observable) Skippable (notifies Compose directly of changes) mutableStateOf() wrapper

Pitfall — Passing Unstable Lambdas: Inline lambdas that capture unstable variables (like collections) are re-instantiated on every parent recomposition. Since the lambda reference changes, children that accept this lambda as an argument are forced to recompose, breaking stability optimizations. Prevent this by wrapping lambdas in remember blocks before passing them down the tree.


8. Advanced: Comparison of State APIs

8.1 State Optimization Matrix

The table below compares the performance and behavior of different Compose state APIs:

API Signature Storage Location Primary Responsibility Recomposition Impact
remember { ... } Composition Slot Table Cache values across recompositions None (caching only, does not track changes)
mutableStateOf(x) State Object Wrapper Track variables; trigger recomposition on write High (triggers recomposition of reading nodes)
derivedStateOf { ... } Derived State Wrapper Filter high-frequency state updates to boolean flags Low (recomposes only when calculated output changes)
rememberSaveable { ... } Android Bundle Storage Preserve state across Activity recreations (screen rotations) None (preserves state across lifecycle events)

Pitfall — Using remember Saveable with massive objects: rememberSaveable uses the Android Bundle system behind the scenes. Storing large bitmaps or complex data lists in this bundle can trigger a TransactionTooLargeException, causing application crashes on screen rotation. Keep saveable data lightweight, storing only IDs or lookup parameters.

8.2 Internals of the Composition Slot Table and Gap Buffer

To understand how remember caches variables without heap object allocations on every call, we must examine the **Composition Slot Table**. The slot table is the internal database used by the Compose runtime to store the structure of the UI tree and all cached states. Programmatically, it is implemented as a flat array structure called a **Gap Buffer**.

A Gap Buffer is an array that reserves an empty segment (the "gap") in the middle of its memory space. When the composable function executes sequentially, it writes data entries (like UI nodes, keys, and remembered values) into the buffer. The read/write cursor operates at the boundary of the gap. If the composable tree structure remains identical, writes are extremely fast because they require zero array resizing or shifting:

$$ \text{SlotArray} = \big[\underbrace{D_1, D_2, \dots, D_i}_{\text{Before Gap}}, \underbrace{\text{\_\_\_}, \text{\_\_\_}, \dots, \text{\_\_\_}}_{\text{Gap Space}}, \underbrace{D_{i+1}, \dots, D_n}_{\text{After Gap}}\big] $$

If recomposition deletes a node (e.g. an if block evaluates to false), the gap is simply shifted to the deletion index, and the gap size is increased by adjusting pointers. If a new node is inserted, the gap is shifted to the insertion index, and data is written into the empty gap space. This flat array design bypasses the overhead of traditional linked-list UI trees, ensuring cache-friendly sequential memory reads and writes during recomposition loops.


9. Complete Jetpack Compose Custom Counter and Network Loading List Implementation

9.1 The Kotlin Code

The following Kotlin component demonstrates state management, derivedStateOf optimizations, and LaunchedEffect side effects in Jetpack Compose:

@Composable
fun UserDashboard(userId: String) {
    var counter by remember { mutableStateOf(0) }
    var userData by remember { mutableStateOf<User?>(null) }
 
    // Derived State: checks if counter exceeded a specific threshold
    val isLimitReached by remember {
        derivedStateOf { counter > 10 }
    }
 
    // Side Effect: fetches user details when userId changes
    LaunchedEffect(userId) {
        userData = repository.fetchUserData(userId)
    }
 
    Column(modifier = Modifier.padding(16.dp)) {
        Text(text = "Dashboard for ${userData?.name ?: \"Loading...\"}")
        Button(onClick = { counter++ }) {
            Text(text = "Increment: ${counter}")
        }
        if (isLimitReached) {
            Text(text = "Limit exceeded!", color = Color.Red)
        }
    }
}

10. Interactive: Recomposition Visualizer

Click "Step Recomposition" to trace how Compose updates its nodes. Watch state changes skip execution of Child A while trigger-recomposing Child B:

Status: Ready
Starting state update.
Log output displays here...
Parent Layout
State: v1.0
Child A
Status: Stable
Child B
Status: Pending

Recomposition Performance comparison

The chart below compares the rendering frame times (in milliseconds) for skippable composables vs un-skippable composables containing unstable parameters:


12. Frequently Asked Questions

Q1: Can a composable function run on a background thread?

Yes. While the initial Composition is executed on the main thread, the Compose runtime reserve the right to run recompositions on background threads to parallelize layout calculations. Therefore, composable functions must remain strictly side-effect free.

Q2: Why does remember not survive screen rotations?

Because screen rotation destroys the Activity, which tears down the Composition tree and discards its internal slot tables. To survive rotation, use rememberSaveable, which writes variables to the persistent Android Bundle.

Q3: How does Compose track when a state variable is read?

Compose uses thread-local observation. During the execution of a composable, the runtime monitors all read accesses to state objects and logs the calling function as a subscriber in its dependency maps.

Q4: Why does a List parameter make a Composable un-skippable?

Because the default Kotlin collections are defined as interfaces, and the compiler cannot guarantee at compile-time that their runtime implementation is immutable. An implementation could be cast to a MutableList and modified, so the compiler marks them unstable.

Q5: What is the difference between derivedStateOf and standard remember with keys?

remember(key) recalculates its block only when the key changes. derivedStateOf calculates state dynamically and only triggers recompositions when the final output changes, filtering high-frequency updates.

Q6: What is a recomposition feedback loop?

A feedback loop is a bug where executing a composable modifies a state variable that the composable itself reads. This triggers another recomposition immediately, creating an infinite execution loop that drains the battery and freezes the UI.

Q7: Can I use standard LiveData or RxJava inside Jetpack Compose?

Yes, but they must be converted to State wrappers. Compose provides extensions like observeAsState() or collectAsStateWithLifecycle() to convert standard streams into Compose-native State objects safely.

Q8: How do I profile recompositions in my app?

Verify: (1) use the Layout Inspector in Android Studio to observe recomposition counts, (2) check if parent updates recompose child nodes unnecessarily, (3) verify that all custom models are marked @Stable, and (4) verify that derivedStateOf filters scroll offsets.

Post a Comment

Previous Post Next Post