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 |
|---|---|
|
|
|
|
|
|
|
|
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 |
|---|---|
|
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
|
|
Obtain a |
|
Wrap a value read inside a long-lived effect (typically |
|
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 |
|
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. |
|
Convert a non-Compose asynchronous source (a callback-based API, a |
|
Run a fire-and-forget block exactly when a specific |
|
The paired-resource version of |
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
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.