Android Development

    Jetpack Compose Under the Hood: How Recomposition, remember, and key() Really Work

    A
    AzeemOct 7, 2026 · 11 min read
    121
    Jetpack Compose Under the Hood: How Recomposition, remember, and key() Really Work

    Jetpack Compose looks deceptively simple. Write a composable. Pass some state. The UI updates. For a while, it feels almost magical.

    Then you hit your first weird bug:

    • A list item “remembers” the wrong state after you insert a new item.

    • A remembered value doesn’t reset when you expect it to.

    • A composable recomposes way more (or way less) than you think it should.

    That’s the moment you realise: Compose isn’t just a declarative UI toolkit. It has a runtime with its own model of identity, state, change, and execution. Once that model clicks, the “magic” becomes predictable.

    This article is a practical, non-boring walkthrough of that mental model. We’ll connect:

    • Recomposition

    • Identity and the Slot Table

    • remember and key()

    • Stability and skipping

    • Snapshots

    • Side effects (LaunchedEffect, DisposableEffect, SideEffect)

    By the end, you’ll have a single, usable picture of how Compose actually works under the hood.

    Why Compose Needed a New Runtime

    Traditional Android UI is largely imperative:

    • You have a XML or View hierarchy.

    • You find a View with findViewById or binding.

    • You call setText(), setVisibility(), etc.

    • You manually keep the UI in sync with your state.

    Compose flips the problem:

    Instead of telling the UI how to change, you describe what the UI should look like for a given state.

    @Composable
    fun UserScreen(user: User) {
        if (user.isLoggedIn) {
            Text("Welcome ${user.name}")
        } else {
            Text("Please log in")
        }
    }
    code

    This code expresses a relationship: given this user, this is the UI that should exist.

    Now imagine user.name changes.

    • Does Compose re-run the entire screen? It could, but that would be wasteful.

    • Instead, the runtime needs to answer:

      • What code previously produced this UI?

      • Which state did it read?

      • What changed?

      • Which parts need to run again, and which can be skipped?

    That decision-making is the Compose runtime.

    Recomposition: Selective Work, Not a Full Redraw

    Recomposition is Compose selectively revisiting previously composed code because something that code depends on has changed.

    It’s not “Compose redraws everything”. It’s “Compose figures out what actually needs to run again”.

    Take a simple counter:

    @Composable
    fun Counter() {
        var count by remember { mutableStateOf(0) }
    
        Column {
            Text("Count: $count")
            Button(onClick = { count++ }) {
                Text("Increment")
            }
        }
    }
    code

    When count changes:

    • Compose doesn’t rebuild the entire app.

    • It knows which parts of the composition read count.

    • It invalidates just those parts and re-executes them.

    Think of the flow like this:

    State change → invalidation → recomposition → execution → updated UI

    Recomposition is selective work, not a blanket redraw.

    Identity: How Compose Knows “This Is Still the Same Thing”

    If you call the same composable twice:

    UserRow(user1)
    UserRow(user2)
    code

    The function name is identical. How does Compose know which instance is which?

    Call-site identity

    Compose uses the call site—the position in the composition tree—as part of identity.

    • Each composable call gets a “slot” in the runtime’s internal structure.

    • That slot is identified by where it was called, not just by the function name.

    This works great… until you have lists.

    Lists and the key() problem

    Consider:

    users.forEach { user ->
        UserRow(user)
    }
    code

    Now insert a new user at the top of the list.

    • All subsequent items shift position.

    • From Compose’s perspective, the slot positions change.

    • Remembered state can end up attached to the wrong item.

    Solution: explicit keys.

    users.forEach { user ->
        key(user.id) {
            UserRow(user)
        }
    }
    code

    key() tells Compose:

    “This chunk of UI is identified by user.id. If that ID changes, treat it as a completely new subtree.”

    Use key() when:

    • Items move around in a list.

    • You swap between different entities in the same composable position.

    • You want remembered state to reset when the logical identity changes.

    The Slot Table: Compose’s Runtime Notebook

    The Slot Table is best understood as Compose’s internal notebook that tracks the composition.

    Conceptually:

    • During composition, Compose records information about each composable call.

    • It stores this in a structure called the Slot Table.

    • On recomposition, it walks this table to match old and new calls.

    Key points:

    • Slots are tied to position in the tree, not function names.

    • Order matters more than you think.

    • remember stores its value in this table, associated with a specific slot.

    So when you see:

    val value = remember { expensiveCalculation() }
    code

    think:

    “Store expensiveCalculation() in the slot for this call site. On recomposition, if this slot still exists and is valid, give me back the stored value instead of recalculating.”

    remember: Tied to Composition Identity, Not to “The Function”

    A common description: “remember survives recomposition.”

    More precisely:

    remember associates a value with a location in the composition and returns the existing value when that identity remains appropriate.

    Consequences:

    • If the composable stays in the same position, remember keeps the value.

    • If the composable leaves the composition (e.g., inside an if that becomes false), its slots are removed and the value is lost.

    • If you move a remember into a different branch or wrap it in a new composable, you change its identity and thus its lifetime.

    A realistic bug pattern

    @Composable
    fun ItemEditor(item: Item) {
        var draft by remember { mutableStateOf(item.note) }
        // ...
    }
    code

    Later, you change item but keep the same composable position.

    • draft does not reset.

    • Compose thinks it’s the same slot, so it reuses the old remembered value.

    To reset when the item changes, use key():

    key(item.id) {
        ItemEditor(item)
    }
    code

    Now, when item.id changes, Compose treats the inside as brand new, discarding old remembered state.

    Stability: When Compose Decides to Do Nothing

    One of Compose’s most powerful optimisations is skipping: not running a composable at all when nothing relevant has changed.

    Flow:

    Something changed → composable becomes relevant → inputs are considered → composable may recompose or may be skipped.

    What is stability?

    Stability is information that allows Compose to reason about whether a value can be considered unchanged for recomposition purposes.

    • @Stable and @Immutable are hints to the compiler/runtime.

    • They don’t magically make things stable; they express guarantees about how the type behaves.

    Example:

    @Stable
    class UserState {
        var name by mutableStateOf("")
    }
    code

    Here, UserState is stable because:

    • Its properties are backed by mutableStateOf, which Compose can track.

    • Compose can safely skip composables that only read UserState if no observed state changed.

    Mental model:

    state → observation → invalidation → identity → stability → skip or recompose

    Snapshots: The Invisible State System

    It’s tempting to think of:

    val count by remember { mutableStateOf(0) }
    Text("$count")
    code

    as “a mutable variable that triggers recomposition”.

    In reality, Compose uses a snapshot system to manage observable state.

    Key ideas:

    • When a composable reads state, that read is tracked as a dependency.

    • When state writes happen, Compose knows which parts of the composition depend on that state.

    • This read–write relationship is what drives invalidation and recomposition.

    Snapshots also provide:

    • Controlled views of mutable state.

    • Consistency and isolation across threads and execution contexts.

    So the chain is:

    • Composable reads count → dependency recorded.

    • count changes → snapshot marks dependent slots invalid.

    • Compose schedules recomposition for those slots.

    Side Effects: When UI Code Touches the Outside World

    Composables describe UI, but apps also need to:

    • Launch coroutines.

    • Register listeners.

    • Interact with lifecycle.

    • Perform one-time actions.

    Doing this directly in the body of a composable is dangerous because the composable can run many times. That’s why Compose provides effect APIs with their own lifecycles.

    LaunchedEffect

    LaunchedEffect(userId) {
        viewModel.loadUser(userId)
    }
    code
    • Launches a coroutine when the composable enters composition.

    • Cancels when the composable leaves composition.

    • Restarts when the keys change.

    Lifecycle:

    • Enters composition → coroutine starts.

    • Keys change → old coroutine cancelled, new one launched.

    • Leaves composition → coroutine cancelled.

    DisposableEffect

    DisposableEffect(lifecycleOwner) {
        val observer = ...
        lifecycleOwner.lifecycle.addObserver(observer)
    
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
    code
    • Runs setup code.

    • Runs onDispose when:

      • The composable leaves composition, or

      • The keys change.

    Use this for anything that needs cleanup: listeners, subscriptions, timers, etc.

    SideEffect

    SideEffect {
        analytics.setUserProperty("screen", "Home")
    }
    code
    • Runs after every successful recomposition (unless keyed).

    • Useful for pushing Compose state out to non-Compose systems.

    Key distinction:

    Composition and external effects have different lifecycles. Effects are your safe bridge between the two.

    Putting It All Together: A Full Runtime Walkthrough

    Let’s see all these ideas in one screen:

    @Composable
    fun UserScreen(userId: String) {
        var refreshCount by remember { mutableStateOf(0) }
    
        LaunchedEffect(userId) {
            // Load user
        }
    
        Column {
            UserHeader(userId)
            Text("Refreshes: $refreshCount")
            Button(onClick = { refreshCount++ }) {
                Text("Refresh")
            }
        }
    }
    code

    What happens when you click “Refresh”?

    1. State change
      refreshCount++ writes to a mutableStateOf.

    2. Snapshot system participates
      The write goes through Compose’s snapshot mechanism.

    3. Dependencies are considered
      Compose knows which slots read refreshCount (the Text).

    4. Recomposition occurs
      Only the affected portion of the composition is scheduled for recomposition.

    5. Identity preserves structure
      Compose matches the new execution against the existing composition using call-site identity and keys.

    6. remember retrieves its existing value
      refreshCount’s slot still exists, so remember returns the updated value instead of reinitialising.

    7. Stability influences skipping
      Children whose relevant inputs haven’t changed may be skipped entirely.

    8. Effects have their own lifecycle
      Changing refreshCount does not restart LaunchedEffect(userId) because its keys didn’t change.

    The complete mental model:

    • State is read → dependency tracked.

    • State changes → invalidation.

    • Recomposition runs only what’s needed.

    • Identity + Slot Table preserve structure and remembered values.

    • Stability allows skipping.

    • Effects run with their own lifecycle rules.

    Compose Runtime Cheat Sheet (Plain Language)

    Use this as a quick reference when debugging or designing composables.

    • Composition
      Compose executes composable code and builds the UI representation.

    • Recomposition
      Selectively revisiting previously composed code because relevant state or inputs changed.

    • Identity
      How Compose distinguishes one compositional instance from another (via call site and keys).

    • Call site
      Where a composable is invoked; part of its identity.

    • key()
      Explicit identity for chunks of UI, especially in lists or when swapping items.

    • Slot Table
      Internal structure that maintains information associated with the composition (including remembered values).

    • remember
      Associates a value with a location in the composition so it survives recomposition while that identity remains appropriate.

    • Snapshot
      Compose’s state-management mechanism for tracking and coordinating state reads and writes.

    • Stability
      Information that lets Compose reason about whether a value can be considered unchanged, enabling skipping.

    • Skipping
      Avoiding execution of a composable when Compose can determine recomposition isn’t necessary.

    • LaunchedEffect
      Composition-aware coroutine launcher; lifecycle tied to composition, restarts on key change.

    • DisposableEffect
      Like LaunchedEffect, but with explicit cleanup via onDispose.

    • SideEffect
      Runs after successful recomposition; useful for pushing state out to non-Compose systems.

    Debugging Checklist: Ask These Questions First

    When something feels “off” in Compose, don’t jump straight to “which API should I use?”. Ask:

    • What changed?
      Which state write triggered this?

    • What was read?
      Which composables depend on that state?

    • Did identity change?
      Did I move a composable, change an if branch, or alter the tree structure?

    • Is remember in the right place?
      Am I accidentally tying remembered state to the wrong slot?

    • Do I need key()?
      Are items moving around or swapping in the same position?

    • Are my effects keyed correctly?
      Should this LaunchedEffect or DisposableEffect restart on this input?

    • Can this be skipped?
      Are my types stable enough for Compose to skip work?

    Once you start thinking in terms of state, identity, and runtime behaviour, Compose becomes a lot less magical—and a lot more understandable.

    Want a Structured Path to Become Android Job-Ready?

    If you already know Android but feel stuck with Modern Android, System Design, or interviews, you don’t need to start from scratch. You need the right structure, practice, and feedback.

    Join 12 weeks Android Job Ready Bootcamp designed for senior android developer.

    In 12 weeks, you’ll work on:

    → Jetpack Compose, Coroutines & Flow
    → Clean Architecture, MVVM & Hilt
    → Android Internals & Performance
    → LLD + HLD Mobile System Design
    → Machine Coding & Interview Preparation
    → CI/CD, Security & Production Practices
    → Real-world project
    → Mock interviews + Resume/LinkedIn review
    → 1:1 mentorship & referral support

    The goal isn’t just to learn Android. It’s to become confident enough to clear Android interviews and build production-grade applications.

    👉 Explore the Android Job Ready Bootcamp:
    Master Android Job Ready Bootcamp

    Jetpack Compose
    Android Development
    Kotlin
    Mobile Development
    Software Engineering
    Jetpack Compose Internals
    Was this helpful?

    Comments (0)

    No comments yet. Be the first to share your thoughts!

    Leave a Comment

    Get the latest updates

    Join our newsletter and never miss an article.

    By subscribing, you agree to our Privacy Policy and consent to receive updates.

    Ready to Start Learning?

    Join thousands of students building their dream careers

    Explore Courses