State, Recomposition and Side Effects in Compose

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Jetpack Compose re-runs (recomposes) the composable functions that read a piece of state whenever that state changes, and skips every composable that does not depend on it. Almost everything else this page covers — remember, state hoisting, derivedStateOf, stability, the effect APIs — exists to make that re-run cheap, predictable and limited to exactly the composables that actually need it. This page covers Compose’s state and side-effect model as of Compose BOM 2026.09.00 (Compose runtime/UI 1.12.1) and Kotlin 2.4.20, building on Jetpack Compose Fundamentals and Compose Layouts, Modifiers and Lazy Lists. The running example is TrailMate's Compose build: TrailListScreen collecting TrailListViewModel’s `StateFlow<TrailListUiState>, and TrailDetailScreen reading a trailId that TrailDetailViewModel restores from SavedStateHandle.

Saving state so it survives a configuration change or process death — rememberSaveable’s `Saver contract, ViewModel+SavedStateHandle, and how the two stacks compare — is covered in depth on Saving UI State: Views vs. Compose; this page only introduces rememberSaveable as one of the tools Compose state uses day to day. The ViewModel itself, viewModelScope and how a ViewModel fits into Compose is covered on ViewModel, Lifecycle-Aware Components, Coroutines and Flow; coroutines and Flow themselves are Kotlin-language topics covered on Coroutines Basics and Flows.

Recomposition and the Composition

A @Composable function is not a view that is built once and then mutated — it is a description of UI that Compose re-invokes, in whole or in part, every time the data it read changes. The Composition is the tree of composables Compose has already run and is tracking; recomposition is Compose calling some of those composable functions again, in response to a snapshot state object changing, and reconciling the result against what is already on screen. Three properties of recomposition matter for everything else on this page:

  • Recomposition is granular. Only the composable functions that actually read a changed state value are scheduled to recompose — a sibling composable that never reads that value is skipped entirely, an outcome called skipping.

  • A composable function can run zero, one or many times for a single logical change, and it can run in any order, including out of order and in parallel with other composables. Because of this, a composable body must be free of side effects — it must not write to a database, mutate a shared variable, or launch a coroutine directly — side effects belong in one of the effect APIs in Effect APIs below, which run at a well-defined point in the composable’s lifecycle instead of on every recomposition attempt.

  • Recomposition is optimistic and can be skipped or restarted. Compose may start recomposing a function, abandon that attempt because a dependency changed again mid-frame, and start over, so a composable body must also be idempotent — calling it twice with the same inputs must produce the same result.

See Android Developers — Thinking in Compose and Android Developers — Phases of Jetpack Compose for how composition, layout and drawing relate to each other.

State in Compose: remember and mutableStateOf

mutableStateOf(value) creates a MutableState<T>, an observable holder Compose’s snapshot system tracks reads and writes of. Reading .value inside a composable registers that composable as a subscriber; writing .value schedules every subscriber for recomposition. On its own, mutableStateOf is not enough inside a composable function body, because the function itself is re-invoked on every recomposition — without remember, a fresh MutableState (reset to its initial value) would be created on every single recomposition, discarding whatever the user had typed or toggled. remember is what keeps the same MutableState instance alive across recompositions, keyed to that call site in the Composition:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailSearchBar.kt
@Composable
fun TrailSearchBar(onQueryChange: (String) -> Unit) {
    var query by remember { mutableStateOf("") }

    OutlinedTextField(
        value = query,
        onValueChange = { newValue ->
            query = newValue
            onQueryChange(newValue)
        },
        label = { Text("Search trails") },
    )
}

The by delegate (import androidx.compose.runtime.getValue and .setValue) reads and writes query directly as a String, rather than through .value; both forms compile to the same underlying MutableState, and by is the convention this page and the rest of the Android section use. remember survives recomposition but not a configuration change or process death that destroys and re-creates the whole Activity — for that, rememberSaveable below, or state hoisted into a ViewModel, is needed instead. See Android Developers — State in Jetpack Compose.

Primitive and Collection State Variants

mutableStateOf<T> boxes any value in a generic MutableState<T>, which for a primitive means an extra autoboxing allocation on every write. Compose ships specialized variants that hold the primitive unboxed:

Function Holds

mutableIntStateOf(value)

MutableIntState, an unboxed Int — used by TrailListViewModel internals such as a visible-item count.

mutableLongStateOf(value)

MutableLongState, an unboxed Long.

mutableFloatStateOf(value)

MutableFloatState, an unboxed Float — common for scroll offsets and animation progress.

mutableDoubleStateOf(value)

MutableDoubleState, an unboxed Double — TrailMate uses this for a trail’s lengthKm shown while editing a custom hike entry.

For collections, wrapping a plain MutableList/MutableMap in mutableStateOf only notifies observers when the reference itself is replaced, not when an element inside it is added or removed — a list.add(item) call mutates the list in place without ever writing to the MutableState, so nothing recomposes. mutableStateListOf/mutableStateMapOf fix this by making the collection itself observable at the element level:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoCaptureScreen.kt
@Composable
fun PhotoCaptureScreen() {
    val capturedUris = remember { mutableStateListOf<String>() }

    Column {
        Button(onClick = { capturedUris.add(capturePhotoUri()) }) {
            Text("Capture photo")
        }
        capturedUris.forEach { uri -> PhotoThumbnail(uri) }
    }
}

TrailMate’s real screens mostly avoid this pattern in favor of hoisting the list into TrailListUiState as an immutable List<Trail> produced by the ViewModel (see State Hoisting: Stateless vs. Stateful Composables) — mutableStateListOf is most useful for UI-only state that never needs to leave the composable, such as a multi-select set of expanded item IDs. See Android Developers — androidx.compose.runtime reference.

State Hoisting: Stateless vs. Stateful Composables

State hoisting means moving a composable’s remember`ed state up to its caller, replacing the internal `MutableState with a value: T parameter and an onValueChange: (T) → Unit (or a more specific event callback) lambda. A composable that owns no state of its own — every value it shows and every event it raises comes through parameters — is stateless; a composable that holds its own `remember`ed state is stateful. Almost every reusable composable in TrailMate is written stateless, with a single stateful wrapper at the screen level that owns the source of truth:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
// Stateless: no remember, no ViewModel reference -- easy to preview and reuse.
@Composable
fun TrailListContent(
    uiState: TrailListUiState,
    onTrailClick: (String) -> Unit,
    onToggleFavorite: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    LazyColumn(modifier = modifier) {
        items(uiState.trails, key = { it.id }) { trail ->
            TrailListItem(
                trail = trail,
                onClick = { onTrailClick(trail.id) },
                onFavoriteClick = { onToggleFavorite(trail.id) },
            )
        }
    }
}

// Stateful: owns the ViewModel and hoists its StateFlow down as plain parameters.
@Composable
fun TrailListScreen(
    onTrailClick: (String) -> Unit,
    viewModel: TrailListViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    TrailListContent(
        uiState = uiState,
        onTrailClick = onTrailClick,
        onToggleFavorite = viewModel::onToggleFavorite,
    )
}

This split pays off in three ways: TrailListContent can be exercised in a @Preview or a Compose UI test (Instrumented and UI Testing) with a hand-built TrailListUiState and no ViewModel or Hilt graph at all; it can be reused from a different screen that sources its data differently; and only TrailListScreen needs to change if the state source ever changes (a different ViewModel, a SavedStateHandle-backed flow, a preview harness). The Figure: Unidirectional Data Flow figure below generalizes this exact shape — state flows one level down through parameters, events flow one level up through callbacks — across the whole screen hierarchy, not just one composable. See Android Developers — State Hoisting in Jetpack Compose and Recommended App Architecture for how this hoisting pattern extends up through the ViewModel and into the UI layer as a whole.

rememberSaveable

remember loses its value across a configuration change or process death, because both destroy and recreate the composable’s entire Activity. rememberSaveable behaves like remember but additionally saves its value into the same Bundle-based mechanism an Activity’s `onSaveInstanceState uses, so it survives both:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailSearchBar.kt
@Composable
fun TrailSearchBar(onQueryChange: (String) -> Unit) {
    var query by rememberSaveable { mutableStateOf("") }
    // survives rotation and (within Bundle size limits) process death
    OutlinedTextField(value = query, onValueChange = { query = it; onQueryChange(it) })
}

rememberSaveable works out of the box for the types Bundle already knows how to save (primitives, String, Parcelable, @Parcelize data classes) and, since Compose keeps mutableStateOf and its collection variants Saver-aware, for MutableState wrapping one of those types directly. A type that is not natively saveable needs a custom Saver (or the mapSaver/listSaver builders) supplied through rememberSaveable’s `saver parameter, telling Compose how to convert it to and from a saveable representation. The full mechanics — writing a Saver, SaveableStateHolder for a list of destinations, and how rememberSaveable compares to ViewModel+SavedStateHandle for surviving process death specifically — are covered on Saving UI State: Views vs. Compose. See Android Developers — Save UI State in Compose.

State-Holder Classes

Once a composable’s remember`ed state grows past one or two simple values — multiple related fields, or logic that reacts to more than one of them together — moving that state and its logic into a plain Kotlin class, a state holder, keeps the composable itself declarative. TrailMate’s `PhotoCaptureScreen hoists its camera permission state, capture progress and error message into one:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoCaptureState.kt
@Stable
class PhotoCaptureState(
    initialUris: List<String>,
) {
    var capturedUris by mutableStateOf(initialUris)
        private set
    var errorMessage by mutableStateOf<String?>(null)
        private set

    fun onPhotoCaptured(uri: String) {
        capturedUris = capturedUris + uri
        errorMessage = null
    }

    fun onCaptureFailed(reason: String) {
        errorMessage = reason
    }

    companion object {
        val Saver: Saver<PhotoCaptureState, *> = listSaver(
            save = { listOf(it.capturedUris) },
            restore = { PhotoCaptureState(initialUris = it[0]) },
        )
    }
}

@Composable
fun rememberPhotoCaptureState(initialUris: List<String> = emptyList()): PhotoCaptureState =
    rememberSaveable(saver = PhotoCaptureState.Saver) { PhotoCaptureState(initialUris) }

A state holder is Compose’s answer to "screen-level logic that is not complex enough to need a ViewModel`" — it is a plain object, constructed with `remember/rememberSaveable rather than injected, and it is scoped to the composition, not to a ViewModelStore. A ViewModel remains the right choice whenever the state must survive configuration changes and the composable’s own removal from the Composition (navigating away and back), must be shared with a non-Compose caller, or needs dependency injection — ViewModel, Lifecycle-Aware Components, Coroutines and Flow covers that boundary directly. See Android Developers — State Holders and State Production.

derivedStateOf and snapshotFlow

derivedStateOf computes a new state value from one or more other state objects, and — unlike a plain local val recomputed on every recomposition — only notifies its own readers when the computed result itself changes, not every time an input it reads changes. It matters most when the inputs change far more often than the derived result does. TrailMate uses it to show a "scroll to top" button only once the list has scrolled past its first few items, rather than on every pixel of scroll:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
val listState = rememberLazyListState()
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 3 }
}

Without derivedStateOf, reading listState.firstVisibleItemIndex directly in the composable that shows the button would recompose that composable on every scrolled pixel, even though showScrollToTop itself only flips twice per scroll session; with it, that composable only recomposes on the two frames where the boolean actually changes.

snapshotFlow { …​ } runs the opposite direction: it turns Compose’s snapshot state into a cold Kotlin Flow, re-emitting the block’s result every time one of the state objects it reads changes, with the usual Flow operators (distinctUntilChanged, debounce, filter) available on top. TrailMate uses it to trigger a one-shot side effect — logging that the user reached the end of the trail list — as an ordinary Flow collection inside a LaunchedEffect rather than a raw state read:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
LaunchedEffect(listState) {
    snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
        .filterNotNull()
        .distinctUntilChanged()
        .collect { lastVisibleIndex ->
            if (lastVisibleIndex >= uiState.trails.lastIndex) {
                analytics.logTrailListEndReached()
            }
        }
}

See Android Developers — derivedStateOf and Flows for Flow operators in general.

Collecting Flows Safely: collectAsStateWithLifecycle

A ViewModel’s `StateFlow keeps emitting for as long as the ViewModel is alive, which is longer than the screen observing it is actually on screen and visible to the user — collecting it unconditionally wastes battery and CPU work updating UI state nobody can see. collectAsStateWithLifecycle(), from androidx.lifecycle:lifecycle-runtime-compose (part of Lifecycle 2.11.0), collects the flow only while the LocalLifecycleOwner is at least STARTED, and automatically stops and restarts collection as the screen leaves and re-enters the foreground — the Compose equivalent of repeatOnLifecycle(STARTED) used with Flow.collect in the legacy Views stack (Fragments and the Fragment Lifecycle):

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
@Composable
fun TrailListScreen(
    onTrailClick: (String) -> Unit,
    viewModel: TrailListViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // uiState is a plain State<TrailListUiState>; reading it recomposes TrailListScreen
    // whenever a new value arrives while the screen is at least STARTED.
    TrailListContent(uiState = uiState, onTrailClick = onTrailClick, onToggleFavorite = viewModel::onToggleFavorite)
}

collectAsState() (without the lifecycle suffix), from plain androidx.compose.runtime, collects unconditionally regardless of lifecycle state and should be reserved for a Flow that is cheap and genuinely needs to keep running in the background, or for non-Android Kotlin Multiplatform code with no lifecycle to key off; for a ViewModel-backed screen StateFlow such as TrailListViewModel.uiState, collectAsStateWithLifecycle() is the recommended default. See Android Developers — Lifecycle in Jetpack Compose and ViewModel, Lifecycle-Aware Components, Coroutines and Flow.

Stability and Skipping

Compose can skip recomposing a function entirely if none of its parameters changed since the last call — but only if the compiler can prove each parameter type is stable: a type whose public properties never change without notifying the Composition (equality is consistent and permanent), or which is itself observable state. A stable type lets Compose compare it by equals() and confidently skip; an unstable type — most commonly, a data class holding a var or a List/Map/Set (whose standard-library interfaces are not provably immutable) — disables skipping for every composable that takes it as a parameter, even when its runtime value did not actually change.

TrailListUiState is exactly this trap if written carelessly:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListUiState.kt
// Unstable: List is a plain interface, not provably immutable to the compiler.
data class TrailListUiState(
    val trails: List<Trail>,
    val isLoading: Boolean,
)

// Marking it @Immutable is a promise to the compiler that trails is never mutated in place
// after this instance is created -- true here because OfflineFirstTrailRepository always
// emits a fresh List.
@Immutable
data class TrailListUiStateStable(
    val trails: List<Trail>,
    val isLoading: Boolean,
)

@Immutable promises every property is fixed for the object’s lifetime; @Stable is the weaker promise that properties may change, but any change is signaled through Compose state so the Composition always finds out — the right annotation for a state-holder class such as PhotoCaptureState in State-Holder Classes, whose mutableStateOf-backed properties do change over time but always observably.

Strong skipping mode, enabled by default since Kotlin 2.0.20 (the Compose compiler plugin is versioned with Kotlin itself since 2.0, so TrailMate’s Kotlin 2.4.20 has it on), additionally lets the compiler treat any composable’s lambda parameters as stable by remembering them automatically, and skips a composable even when one of its unstable parameters is unchanged by reference — it no longer requires every parameter to be individually provably stable, only that the actual values passed did not change. It reduces, but does not eliminate, the value of annotating data classes: an unstable parameter still disables skipping when its value does change on every recomposition (a new List instance with equal contents, for example), so @Immutable/@Stable and returning the same instance when nothing changed both remain worth doing for hot paths like TrailListUiState.

For a class Compose cannot see the source of (a class from an external module or library), or a case where adding the annotation is impractical, a stability configuration file tells the Compose compiler to trust a given type or package as stable without inspecting it:

// app/compose_compiler_config.conf
com.example.trailmate.data.Trail
com.example.trailmate.data.TrailListUiState

wired into the module’s build.gradle.kts via the Compose compiler Gradle plugin’s stabilityConfigurationFile/stabilityConfigurationFiles option. The Layout Inspector's recomposition counts, covered in Measuring Recomposition Counts below, and the Compose compiler’s own reports (-P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=…​) are how a real stability problem in TrailMate is actually found, rather than annotating types speculatively. See Android Developers — Stability in Compose and Android Developers — Strong Skipping Mode.

Effect APIs

A composable body must stay free of side effects (writes outside the Composition, launching a coroutine directly, and so on) because it can run any number of times in any order, as noted in Recomposition and the Composition. Effects are the escape hatch: functions that run a side effect at a well-defined point — entering or leaving the Composition, or when a specific key changes — instead of on every recomposition attempt.

API When to use it

LaunchedEffect(key1, …​) { …​ }

Run a suspend function scoped to the composable’s lifetime, restarted whenever a key changes and cancelled when the composable leaves the Composition. TrailMate uses it to load a trail’s detail once when TrailDetailScreen first shows a given trailId, and for the snapshotFlow collection in derivedStateOf and snapshotFlow.

rememberCoroutineScope()

Obtain a CoroutineScope tied to the composition point, for launching a coroutine from a callback (a button’s onClick) that is not itself a composable and therefore cannot call LaunchedEffect directly.

rememberUpdatedState(value)

Wrap a value read inside a long-lived effect (typically LaunchedEffect or DisposableEffect) that should always see the latest value without the effect itself restarting when that value changes — most often a callback lambda captured by an effect keyed on something else.

DisposableEffect(key1, …​) { onDispose { …​ } }

An effect that must run cleanup when its key changes or it leaves the Composition — registering and later unregistering a listener (a location callback, a BroadcastReceiver) being the canonical case.

SideEffect { …​ }

Publish Compose state to a non-Compose object on every successful recomposition (not skipped, not abandoned) — for example, updating a crash-reporting tool’s custom keys to match the currently shown trail.

produceState(initialValue, key1, …​) { …​ }

Convert a non-Compose asynchronous source (a callback-based API, a Flow with special converging logic) into Compose State, when collectAsStateWithLifecycle does not directly apply.

LifecycleEventEffect(event) { …​ }

Run a fire-and-forget block exactly when a specific Lifecycle.Event is dispatched to the current LocalLifecycleOwner — logging a screen view ON_RESUME, for instance — without the boilerplate of a manual DisposableEffect + LifecycleEventObserver.

LifecycleResumeEffect(key1, …​) { …​; onPauseOrDispose { …​ } } (and the related LifecycleStartEffect)

The paired-resource version of LifecycleEventEffect: start something on ON_RESUME (or ON_START for LifecycleStartEffect) and clean it up in onPauseOrDispose — TrailMate uses LifecycleResumeEffect to start and stop the live GPS updates behind `RecordHikeService’s in-app map preview only while the hike screen is actually in the foreground.

TrailMate’s TrailDetailScreen combines several of these:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
@Composable
fun TrailDetailScreen(
    trailId: String,
    viewModel: TrailDetailViewModel = hiltViewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    val scope = rememberCoroutineScope()
    val currentTrailId by rememberUpdatedState(trailId)

    LaunchedEffect(trailId) {
        viewModel.loadTrail(trailId)
    }

    LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
        analytics.logScreenView("trail_detail", currentTrailId)
    }

    DisposableEffect(Unit) {
        val listener = ConnectivityListener { isOnline -> viewModel.onConnectivityChanged(isOnline) }
        connectivityManager.registerListener(listener)
        onDispose { connectivityManager.unregisterListener(listener) }
    }

    TrailDetailContent(
        uiState = uiState,
        onRetryClick = { scope.launch { viewModel.retry() } },
    )
}

LaunchedEffect(trailId) restarts loadTrail only when trailId itself changes, not on every recomposition; rememberUpdatedState lets the LifecycleEventEffect block always log the current trailId without needing to be keyed on it (which would fire it again on every trailId change, not just ON_RESUME); and DisposableEffect(Unit) registers the connectivity listener exactly once and guarantees it is unregistered when the screen leaves the Composition. See Android Developers — Side-effects in Compose and Android Developers — Integrate Lifecycle with Compose for the full LifecycleEventEffect/LifecycleStartEffect/LifecycleResumeEffect reference.

CompositionLocal

A CompositionLocal implicitly makes a value available to every composable below it in the Composition, without threading it through every intermediate composable’s parameter list. Compose itself relies on this for things like LocalContext, LocalLifecycleOwner and `MaterialTheme’s color scheme; TrailMate exposes its own for the current measurement units preference, which many nested composables need but is awkward to pass down explicitly through every layout in between:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/LocalUnits.kt
val LocalUnits = staticCompositionLocalOf { Units.METRIC }

@Composable
fun TrailMateApp(userPreferences: UserPreferences) {
    CompositionLocalProvider(LocalUnits provides userPreferences.units) {
        TrailMateTheme { TrailNavHost() }
    }
}

staticCompositionLocalOf is the right choice when the value changes rarely (a units preference toggled in Settings, not on every frame) — it skips the extra tracking compositionLocalOf pays for reads, at the cost of recomposing the entire subtree under the CompositionLocalProvider whenever the value does change, rather than only the composables that read it. A CompositionLocal is best reserved for cross-cutting, rarely-changing values like this one; passing TrailListUiState itself through a CompositionLocal instead of explicit parameters, for instance, would defeat the state-hoisting benefits from earlier on this page. See Android Developers — Locally Scoped Data with CompositionLocal.

Common Mistakes

Reading mutable state without remember. var query by mutableStateOf("") declared directly in a composable body (no remember) creates a brand-new MutableState on every recomposition, silently resetting query to "" the moment anything else on the screen recomposes.

Passing an unstable lambda that captures a changing value without rememberUpdatedState. A LaunchedEffect or DisposableEffect that closes over a callback parameter directly, instead of through rememberUpdatedState, either restarts unnecessarily (if the callback is added to the effect’s keys) or silently keeps invoking a stale closure (if it is not).

Hoisting too little, or too much. Leaving state inside a small reusable composable (an expanded/collapsed flag inside TrailListItem, say) prevents the parent from ever driving or observing it — useful only when that state is genuinely private to the composable and nothing external ever needs it. Hoisting everything all the way to the ViewModel, including state that is purely presentational and never persisted (a text field’s current draft before it is submitted), adds unnecessary ViewModel surface and round-trips through StateFlow for something a local remember handles just as well.

Recomposing a LazyColumn item on every scroll because of a missing key. Omitting key = { it.id } in items(…​) (Compose Layouts, Modifiers and Lazy Lists) makes Compose identify items by position rather than identity, which defeats state preservation and skipping when the list is reordered or filtered.

Doing expensive work directly in the composable body instead of remember/derivedStateOf. Sorting or filtering uiState.trails inline on every recomposition, rather than wrapping it in remember(uiState.trails) { …​ } or a derivedStateOf, repeats the work on every recomposition even when the underlying list has not changed.

Treating collectAsState() and collectAsStateWithLifecycle() as interchangeable. Using plain collectAsState() on a screen ViewModel’s `StateFlow keeps collecting — and recomposing — while the screen is backgrounded, wasting battery for no visible benefit; see Collecting Flows Safely: collectAsStateWithLifecycle.

Measuring Recomposition Counts

Guessing at recomposition behavior from reading code is unreliable once stability and skipping are involved — the Layout Inspector’s recomposition counts overlay, run from Android Studio while TrailMate is attached to a debuggable device or emulator, annotates every composable on screen with how many times it has recomposed and how many times it skipped recomposing since the overlay was enabled. A composable whose count keeps climbing while its on-screen content visibly does not change is the practical signal that an unstable parameter, a missing key, or a CompositionLocal change further up the tree is causing unnecessary work, and is the normal way to confirm a stability fix from Stability and Skipping actually worked, rather than assuming it did. The Compose compiler’s metrics/reports Gradle flags mentioned in that section give the same information ahead of time, at build time, across the whole module rather than one screen at a time. See Android Developers — Jetpack Compose Tooling.

Figure: Unidirectional Data Flow

flowchart TB VM["TrailListViewModel
StateFlow<TrailListUiState>"] Screen["TrailListScreen
collectAsStateWithLifecycle()"] Content["TrailListContent
stateless: uiState, onTrailClick, onToggleFavorite"] Item["TrailListItem
stateless: trail, onClick, onFavoriteClick"] VM -->|"state down: TrailListUiState"| Screen Screen -->|"state down: uiState"| Content Content -->|"state down: one Trail"| Item Item -->|"event up: onFavoriteClick()"| Content Content -->|"event up: onToggleFavorite(trailId)"| Screen Screen -->|"event up: viewModel.onToggleFavorite(trailId)"| VM

State flows down one direction, from the single source of truth (TrailListViewModel’s `StateFlow) through each stateless composable as plain parameters; events flow back up the opposite direction, as callback lambdas, until they reach the ViewModel function that actually changes the state. No composable in the middle — TrailListContent, TrailListItem — ever mutates state directly or reaches sideways to a sibling; each one only reads what it was given and calls back what it was given. This is the same unidirectional data flow (UDF) principle Recommended App Architecture applies at the whole-app scale, applied here at the level of a single screen’s composable tree. See Android Developers — Jetpack Compose UI Architecture and Android Developers — Events in the UI Layer.