Thinking in Compose: you can't reach into the UI any more, and that's the point
In Views you fix UI by reaching in and mutating it. Compose removes that ability entirely. Understanding why the removal is the feature is what makes the rest of Compose make sense.

Day 5 of 100. Yesterday was versions. Today is the mental model — the one thing that, if you get it wrong, makes every subsequent Compose API feel arbitrary.
The symptom
Here is a bug that every Android developer has shipped at least once:
// Views
fun showProfile(user: User?) {
if (user != null) {
nameView.text = user.name
avatarView.load(user.avatarUrl)
errorBanner.visibility = View.GONE
} else {
errorBanner.visibility = View.VISIBLE
}
}
Load a user, then load a null. The banner shows, correctly. But nameView still reads
the previous user's name, because nothing told it not to. The UI is now a mix of two
different states, and which parts are stale depends entirely on which branches have
run since the view was inflated.
You fix it by adding the missing line. Then another state arrives — loading, or a partially-populated user — and you add more lines. The function grows a branch per state per widget, and the bug class never actually closes.

Why the obvious fix fails
The obvious fix is discipline: always reset every widget in every branch.
fun showProfile(user: User?) {
nameView.text = user?.name.orEmpty()
avatarView.load(user?.avatarUrl)
errorBanner.isVisible = user == null
}
This is better, and it's still fragile for a structural reason. The correctness of this function depends on it knowing about every widget that any other code path might have touched. Add a subtitle view in a different method six months later and this function is silently wrong again — not because anyone made a mistake here, but because the invariant lives in a developer's head rather than in the type system.
The View system's model is: widgets own state, and you mutate it. Every mutation is a chance to forget one. There is no mechanism that can tell you that you forgot, because "the complete set of things to reset" is not written down anywhere.
The actual mechanism
Compose deletes the mutation API. Not discourages — deletes. There is no
nameView.text = … because there is no nameView.
@Composable
fun Profile(user: User?) {
if (user == null) {
ErrorBanner("Could not load profile")
} else {
Text(user.name)
Avatar(user.avatarUrl)
}
}
This function doesn't update a UI. It describes one, completely, for the state
it was given. When user changes, Compose calls it again and reconciles what changed.
The stale-name bug is not fixed here. It is unrepresentable. There is no code path
that produces "new banner, old name", because there is no persistent nameView
holding the old name between calls. The state you didn't handle can't leak through,
because nothing survives the call to leak.
That's the shift, and it's worth being precise about it: the win isn't fewer lines. It's that a whole category of bug stops being expressible.
Three consequences follow immediately, and they're the reason the rest of the API looks the way it does:
Composables must be cheap and side-effect-free. They may run often, on any frame,
in any order, and Compose may skip or restart them. Anything you do inside one that
isn't "describe UI" — starting a network call, writing to a database, incrementing a
counter — will happen an unpredictable number of times. This is why LaunchedEffect
and friends exist: not ceremony, but the only correct place to put work that must
happen once.
State must live somewhere that outlives the call. If the function runs again from
scratch, a local var is reset every time. remember and mutableStateOf exist to
give values a lifetime tied to the composition rather than the invocation.
Reading state creates a subscription. Compose tracks which composables read which values, so it can re-run exactly those and nothing else. That tracking is why recomposition can be cheap, and why where you read a value matters as much as what you read — a theme that runs through the next twenty posts in this series.
The fix, when the model fights you
The most common early Compose mistake is bringing the mutation habit along:
// Wrong — trying to "update" from outside
@Composable
fun Profile(user: User?) {
var name by remember { mutableStateOf("") }
if (user != null) name = user.name // writing state during composition
Text(name)
}
This compiles, mostly works, and is wrong. It writes state during composition, which can trigger another composition, which writes again. You've reintroduced the thing Compose removed — a value that persists across calls and can disagree with its source.
The corrected version doesn't need the state at all:
@Composable
fun Profile(user: User?) {
Text(user?.name.orEmpty())
}
The rule of thumb: if a value can be derived from what you were passed, derive it.
Only remember things that genuinely cannot be — scroll position, text-field
contents, whether a dialog is open.
How to prove the model to yourself
Put a log inside a composable and drive the state:
@Composable
fun Profile(user: User?) {
Log.d("Compose", "Profile composed with ${user?.name}")
Text(user?.name.orEmpty())
}
Watch what happens when unrelated state elsewhere in the screen changes. Sometimes
Profile re-runs, sometimes it doesn't, and predicting which is the actual skill.
Layout Inspector's recomposition counts show the same information without the logs,
and are the tool worth learning early — they turn "I think this is efficient" into a
number you can check.
Where the model gets genuinely hard
Two places where "describe, don't mutate" stops being obvious and starts being work.
Animations. An animation is inherently about the transition between two states,
and a pure description of the current state has no memory of the previous one. Compose
solves this by keeping the animation's own state in the composition
(animateFloatAsState and friends) so the framework tracks the interpolation for you.
But the first time you want a value to "ease from where it was", the declarative model
feels like it has taken away exactly the thing you need. It hasn't — it has moved it.
Imperative-only APIs. Scrolling a list to an index, showing a snackbar, requesting
focus. These are events, not state; describing them makes no sense, because "scrolled
to index 5" isn't a property of the UI, it's something that happened once. Compose
gives these their own escape hatches — LaunchedEffect, rememberCoroutineScope,
ScrollState.animateScrollToItem — and reaching for them is not a failure of the
model. Using them during composition rather than inside an effect is.
The distinction worth internalising early: state describes what is; events describe what happened. Compose is declarative about the first and explicitly imperative about the second, and most early confusion is trying to force an event into state.
What this generalizes to
The pattern isn't unique to Compose. React made the same move a decade earlier, and SwiftUI made it on the other platform. The common idea: when a bug class comes from partial updates, you eliminate it by making partial updates impossible — describe the whole thing, every time, and let the framework diff.
The cost is real. You give up surgical control, you accept a runtime that decides when your code runs, and you take on a new discipline about where state lives. That trade is worth making, but it's a trade, not a free win — and pretending otherwise is why some teams' first Compose screen ends up slower than the View it replaced.
Tomorrow, Day 6: your first composable, and what @Composable actually does to a
function at compile time.
Day 5 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Thinking in Compose.