DEV Community

Cover image for If/Else in Compose Is Not View.GONE: Where Your State Goes When a Branch Flips
Android Expert
Android Expert

Posted on Originally published at Medium

If/Else in Compose Is Not View.GONE: Where Your State Goes When a Branch Flips

You ship a filter screen. QA toggles between “List” and “Grid”, scrolls the list, types into a search box, switches back, and everything is reset. The code looks harmless:

@Composable
fun Conditional(showA: Boolean) {
    Column {
        if (showA) {
            ComponentA()   // owns remember { } state
        } else {
            ComponentB()   // owns remember { } state
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When showA flips from true to false, ComponentA is not hidden. It is disposed. Every remember, every LaunchedEffect, every DisposableEffect inside it is torn down, and ComponentB is composed from scratch with initial state.

This post covers why, the failure modes that bite in production, and the five tools you actually have for controlling it.

The mental model: hidden means deleted

In the View system, visibility = GONE keeps the node in the tree. The object, its fields, and its listeners all survive. Compose inverts that: the composition describes what exists right now, and anything not described is gone.

views-vs-compose

What actually happens at the slot table

The Compose compiler wraps each branch of a conditional in its own replace group, keyed by call site. Recomposition after showA flips goes like this:

  1. The Column group is still there, so it is reused.
  2. The group for the if branch no longer matches the group the runtime expects at that position. The runtime sees a different key and treats it as a different node, not as an update.
  3. The old group (and everything nested under it) is removed from the slot table. Each RememberObserver receives onForgotten(), DisposableEffects run onDispose, and LaunchedEffect coroutines are cancelled.
  4. The else group is inserted and its remember blocks run their initializers.

slot-table-swap

One detail that surprises people: this is keyed by call site, not by composable function. Calling the same composable from both branches still resets state, because each branch is a distinct group:

// Looks like "the same Player", but state resets on every flip.
if (isCompact) {
    Player(modifier = Modifier.fillMaxWidth())
} else {
    Player(modifier = Modifier.size(320.dp))
}
Enter fullscreen mode Exit fullscreen mode

Failure modes you will meet in production

1. Scroll position and text drafts vanish. rememberLazyListState() and rememberTextFieldState() are backed by rememberSaveable, but that only protects them from configuration change and process death. A branch swap disposes their entries in a live process, so the user is back at the top with an empty field. A plain remember { TextFieldState() } or remember { mutableStateOf("") } fails the same way, just with less protection.

2. Effects re-fire and double your network traffic. Flipping back re-enters the branch, so LaunchedEffect(Unit) { repository.load() } runs again. Rapid toggling produces a burst of cancelled and restarted requests, plus loading spinners that flash on every flip.

3. Heavy resources are rebuilt. An ExoPlayer created in remember and released in onDispose is released and recreated each time. Users hear audio restart; you see MediaCodec init cost in traces.

4. rememberSaveable does not save you here. It survives configuration change and process death, but a branch swap within a live process disposes its entry. People assume "saveable" means "survives anything". It does not.

5. AnimatedVisibility is not a state preserver. This is the most common wrong fix. AnimatedVisibility plays the exit transition and then removes the content from composition. After the exit finishes, remember state is gone, same as if. It is a visual transition, not a lifetime extension.

// Does NOT preserve ComponentA's remember state after the exit animation ends.
AnimatedVisibility(visible = showA) {
    ComponentA()
}
Enter fullscreen mode Exit fullscreen mode

6. Passing a different key() resets by design. key() is often described as a way to preserve state. It does the opposite: a different key means a different identity, hence fresh state. It is a tool for forcing a reset, or for keeping identity stable when position changes (reordering list items).

What survives what

The pattern is clear: if the state lives inside the subtree, anything that removes the subtree removes the state. To keep it, either move the state out, or stop removing the subtree.

state-survival

The toolbox

decision-flow

1. Hoist the state (default choice)

State that must outlive visibility does not belong inside the thing that gets hidden. Lift it to a state holder owned by a stable parent:

@Stable
class ListPaneState(
    initialQuery: String = "",
    firstVisibleItemIndex: Int = 0,
    firstVisibleItemScrollOffset: Int = 0,
) {
    var query by mutableStateOf(initialQuery)
    val listState = LazyListState(firstVisibleItemIndex, firstVisibleItemScrollOffset)

    companion object {
        // Constructing LazyListState directly is fine, but nothing persists it
        // unless you save index and offset yourself.
        val Saver: Saver<ListPaneState, Any> = listSaver(
            save = {
                listOf(
                    it.query,
                    it.listState.firstVisibleItemIndex,
                    it.listState.firstVisibleItemScrollOffset,
                )
            },
            restore = { ListPaneState(it[0] as String, it[1] as Int, it[2] as Int) },
        )
    }
}

@Composable
fun rememberListPaneState(): ListPaneState =
    rememberSaveable(saver = ListPaneState.Saver) { ListPaneState() }

@Composable
fun Conditional(showA: Boolean) {
    val paneState = rememberListPaneState() // lives in the parent, outlives the branch
    Column {
        if (showA) ComponentA(paneState) else ComponentB()
    }
}
Enter fullscreen mode Exit fullscreen mode

For anything that must survive configuration change, put it in a ViewModel and expose it as StateFlow collected with collectAsStateWithLifecycle(). This is also what makes the UI trivially testable.

2. SaveableStateProvider for tabs and switchers

If the content is “one of N screens” and each screen owns its own rememberSaveable state, give each a keyed saveable registry. This is what Navigation Compose does per back-stack entry:

@Composable
fun <K : Any> KeyedSwitcher(
    current: K,          // must be saveable: String, Int, enum name, Parcelable...
    liveKeys: Set<K>,    // every key that can still be navigated to; must contain `current`
    modifier: Modifier = Modifier,
    content: @Composable (K) -> Unit,
) {
    val holder = rememberSaveableStateHolder()
    val seen = remember { mutableSetOf<K>() }

    // Without this, saved state for dead keys accumulates for the whole session.
    LaunchedEffect(current, liveKeys) {
        seen += current
        (seen - liveKeys).forEach { holder.removeState(it) }
        seen.retainAll(liveKeys)
    }

    Box(modifier) {
        holder.SaveableStateProvider(key = current) {
            content(current)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Each key’s rememberSaveable values are stashed when that content leaves composition and restored when it returns. Plain remember is still lost, so use rememberSaveable inside the content. Two operational notes:

  • Cleanup is your job. The holder never evicts anything on its own. For dynamic keys (a tab per open document, say), wire removeState(key) to the moment a key is truly gone, as above. Skipping this is a slow leak that only shows up in long sessions.
  • The holder’s lifetime is the switcher’s lifetime. It is created with rememberSaveableStateHolder(), so if KeyedSwitcher itself leaves composition, the stashed entries go with it. Place the switcher above whatever boundary you need to survive, or hoist the holder.

3. Keep it composed, but not drawn

When the subtree is expensive to rebuild and you must keep everything (animations, scroll, player), keep it in composition and skip placement:

/**
 * Keeps the subtree composed (state and effects alive) but invisible and inert.
 * collapse = false behaves like View.INVISIBLE: the space stays reserved.
 * collapse = true behaves like View.GONE: the subtree takes 0x0.
 */
fun Modifier.hidden(hidden: Boolean, collapse: Boolean = false): Modifier =
    if (!hidden) this else this
        .layout { measurable, constraints ->
            val placeable = measurable.measure(constraints)
            val w = if (collapse) 0 else placeable.width
            val h = if (collapse) 0 else placeable.height
            // Measured but never placed: nothing is drawn, nothing is hit-tested.
            layout(w, h) {}
        }
        .clearAndSetSemantics {} // defensive: keep TalkBack out of the hidden subtree

@Composable
fun Conditional(showA: Boolean) {
    Column {
        // collapse = true, otherwise the hidden one leaves a blank gap in the Column.
        ComponentA(Modifier.hidden(hidden = !showA, collapse = true))
        ComponentB(Modifier.hidden(hidden = showA, collapse = true))
    }
}
Enter fullscreen mode Exit fullscreen mode

Pick the collapse mode deliberately. The default reserves space, which is right for toggling something in place, and wrong for a swap, where it leaves a blank hole.

The trade-off is real: a hidden subtree still composes, still measures, and its LaunchedEffects keep running. A polling loop or a Lottie animation inside it keeps burning battery. Pair it with a visible parameter that your effects respect:

LaunchedEffect(visible) {
    if (visible) feed.collect { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Avoid Modifier.alpha(0f) as the whole solution: it still lays out and, depending on version and parent, can remain reachable by touch and by accessibility services. Verify with TalkBack before shipping.

4. movableContentOf when the same content changes parent

For the “same Player, different container” case, a branch swap loses state but moving content preserves it:

@Composable
fun AdaptivePlayer(isWide: Boolean, url: String) {
    val player = remember(url) {
        movableContentOf { Player(url) }
    }
    if (isWide) {
        Row { player() }
    } else {
        Column { player() }
    }
}
Enter fullscreen mode Exit fullscreen mode

remember state inside Player follows it between the Row and the Column. This only applies when the content is moved; if it is absent from both parents for a frame, it is disposed.

5. key() to reset on purpose

When you want the opposite, such as clearing a form when the selected entity changes, use key:

key(userId) {
    ProfileEditor(userId) // new userId = new identity = fresh state
}
Enter fullscreen mode Exit fullscreen mode

This is the correct replacement for hand-written “reset on id change” LaunchedEffect logic.

Proving it: a test that guards the behavior

Do not trust your memory of this. Encode it:

@Composable
fun Counter(tag: String) {
    var count by remember { mutableIntStateOf(0) }
    Text(
        text = count.toString(),
        modifier = Modifier.testTag(tag).clickable { count++ },
    )
}

class ConditionalStateTest {
    @get:Rule val rule = createComposeRule() // androidx.compose.ui.test.junit4

    @Test
    fun counterResetsWhenBranchSwaps() {
        val showA = mutableStateOf(true)
        rule.setContent {
            if (showA.value) Counter(tag = "a") else Counter(tag = "b")
        }

        rule.onNodeWithTag("a").performClick()
        rule.onNodeWithTag("a").assertTextEquals("1")

        rule.runOnIdle { showA.value = false }
        rule.onNodeWithTag("b").assertTextEquals("0")

        rule.runOnIdle { showA.value = true }
        rule.onNodeWithTag("a").assertTextEquals("0") // state was lost
    }
}
Enter fullscreen mode Exit fullscreen mode

This needs androidx.compose.ui:ui-test-junit4 as an androidTestImplementation (or testImplementation with Robolectric) and ui-test-manifest as debugImplementation.

For quick debugging, drop a lifecycle probe into the suspect composable. Seeing onDispose fire on every toggle ends the argument immediately:

@Composable
fun LifecycleProbe(name: String) {
    DisposableEffect(Unit) {
        Log.d("Probe", "$name entered composition")
        onDispose { Log.d("Probe", "$name left composition") }
    }
}
Enter fullscreen mode Exit fullscreen mode

Rules of thumb

  • Treat every if that gates a composable as a lifetime boundary, not a visibility switch.
  • State that must outlive a boundary lives above it: a hoisted holder, a ViewModel, or a SaveableStateProvider.
  • AnimatedVisibility animates the boundary; it does not remove it.
  • Keeping content composed is a performance decision, not a free lunch. Gate its effects.
  • Reach for key() to reset, movableContentOf to relocate, and neither to preserve across hiding.

The View-era instinct says “hide it and the state will be fine”. In Compose, the question to ask is different: who owns this state, and does that owner outlive the branch?

📱 Go Beyond Using Jetpack Compose

If you’re building on Android, understanding what happens under the hood separates developers who use Compose from those who master it. I highly recommend “Mastering Jetpack Compose Internals”. It’s a deep, architecture-first walkthrough of the composition tree, the slot table, snapshot state, and the runtime that powers modern Android UI — capped off with a full case study building a real app called Mosaic.


Originally published on Medium.

Top comments (0)