Notes

100 days of Compose: the eight ideas the other ninety-nine posts kept circling

A hundred days of Jetpack Compose, written by following the official documentation end to end. The APIs are many and the ideas are few; here are the eight that kept recurring, and an honest account of what the exercise missed.

MobileByteSensei 9 min read
Day 100 — Eight ideas the series kept circling
androidcomposekotlinretrospective

Day 100. Ninety-six posts written by walking the official Compose documentation in order, one topic at a time, from setup-and-bom to glance-widgets.

The thing I didn't expect: the API surface is enormous and the number of ideas is small. By about Day 40 I was mostly writing "this is Day 12's argument, in a new place." That wasn't padding — it's genuinely how the framework is built, and noticing it is the difference between memorising a library and understanding one.

Here are the eight ideas that kept coming back, then an honest account of what this method missed.

Eight recurring ideas, and the days each one surfaced

The case this post is about

One screen state, modelled two ways, with the difference expressed as a count:

boolean combinations: 16
distinct renderings:  4
unrepresentable in the sealed version:
  Flags(loading=true, empty=true, error=true, stale=true) -> renders as "error"

Four booleans is sixteen states and four meanings, which means twelve states the code can reach that nobody designed. They do not crash — the when has an ordering, so each resolves to something — and "loading with an error showing" silently renders as an error while a request is still in flight.

That one comparison is the clearest instance of the thing a hundred days of working through the documentation kept returning to: naming the discrete thing turns a pile of conditionals into a small amount of configuration.

This post follows that count, and then the seven other places the same move showed up — including where the method failed.

1. Declare the destination, not the path

Compose removed the ability to reach into the UI and change it (Day 5). Everything downstream is a consequence.

A dialog isn't showDialog(), it's dialog is ConfirmDelete — a fact that's true until it isn't (Day 39). An animation isn't animate(from, to), it's a target the value converges on, which is why interruption works (Day 62). A swipe isn't a distance compared to a threshold, it's a choice between named anchors (Day 73).

Each time, the payoff is the same: the hard cases — interruption, rotation, reversal, process death — stop being special cases, because there was never a plan to invalidate.

2. The read site is the subscription site

Reading a value is what subscribes a scope to it. That single sentence explains recomposition (Day 7), the three phases (Day 10), why Modifier.offset { } takes a lambda (Day 82), and most of the performance pillar.

It also produces the highest-leverage habit in the whole series: if a value changes every frame, read it in a lambda. Moving one read from composition into layout is two braces and frequently several milliseconds at P99.

3. Identity has to be declared, because the default is positional

Compose knows "the third slot", not "the row belonging to Ada". That's efficient and it's wrong whenever the data moves.

The same bug appears as expansion state on the wrong list row (Day 8), scroll jumps in a LazyColumn (Day 23), a shared element that silently doesn't animate (Day 65), and taps going to the wrong item after slot reuse (Day 71). It's getItemId under a new name (Day 90), and key is the answer every time.

Any time state seems to attach to the wrong thing, the question is whether you told the framework what identity means.

4. Ask the system for the measurement

The most repeated correction in the series, and the one with the widest reach.

isTablet() was never asking about tablets; it was asking about available room, badly (Day 29). A 64dp constant for an app bar is a measurement the scaffold already has (Day 35). A gradient with endX = 1080f is a screen width baked in (Day 58). A 24dp status bar padding is a number that varies per device and per navigation mode (Day 95).

The tell is always the same: a number in the source that came from measuring one device. There's a system-provided alternative in every case, and Android 15's mandatory edge-to-edge is the platform converting that latent assumption into an immediate failure.

5. Name the role, not the value

headlineSmall survives a redesign; 24.sp doesn't, and there's no way to tell which 24.sp meant "headline" and which meant "slightly large body" (Day 46).

The same argument holds for colour — surfaceContainer versus 0xFFF5F5F5 (Day 44) — and for shape (Day 47). It's what makes "keep the brand, adapt the chrome" expressible at all when dynamic colour arrives (Day 45), because primary and surface are different names rather than two hex values.

6. Model the states; don't derive booleans from them

A sealed UiState makes "loading with an error showing" unrepresentable rather than a bug to remember not to write (Day 12). A Transition over an enum is exhaustive, can vary its timing per edge, and coordinates every property that depends on it — none of which a set of extracted booleans can do (Day 63). Anchored dragging turns "where did the finger stop" into "which state did they choose" (Day 73).

Naming the discrete thing is repeatedly what turns a pile of conditionals into a small amount of configuration.

The three examples are the same move, and it is clearest written out once. A set of booleans and the sealed equivalent, with the states each can represent:

// The version that accumulates. Four booleans, sixteen combinations, four of them valid.
data class Flags(val loading: Boolean, val empty: Boolean, val error: Boolean, val stale: Boolean)

fun render(f: Flags): String = when {
    f.error -> "error"
    f.loading -> "spinner"
    f.empty -> "empty state"
    else -> "content"
}

// The version that cannot be wrong. Four states, four combinations.
sealed interface UiState {
    object Loading : UiState
    object Empty : UiState
    data class Error(val message: String, val retryable: Boolean) : UiState
    data class Content(val items: List<Item>, val stale: Boolean) : UiState
}

fun render(s: UiState): String = when (s) {       // exhaustive: the compiler checks it
    UiState.Loading -> "spinner"
    UiState.Empty -> "empty state"
    is UiState.Error -> if (s.retryable) "error + retry" else "error"
    is UiState.Content -> if (s.stale) "content + stale banner" else "content"
}

Enumerating what each can represent is the whole argument:

val all = listOf(true, false).let { bs ->
    bs.flatMap { l -> bs.flatMap { e -> bs.flatMap { r -> bs.map { s -> Flags(l, e, r, s) } } } }
}
println("boolean combinations: ${all.size}")
println("distinct renderings:  ${all.map(::render).distinct().size}")
println("unrepresentable in the sealed version:")
all.filter { (it.loading && it.error) || (it.empty && it.error) || (it.loading && it.empty) }
   .take(4).forEach { println("  $it -> renders as \"${render(it)}\"") }
boolean combinations: 16
distinct renderings:  4
unrepresentable in the sealed version:
  Flags(loading=true, empty=true, error=true, stale=true) -> renders as "error"
  Flags(loading=true, empty=true, error=true, stale=false) -> renders as "error"
  Flags(loading=true, empty=false, error=true, stale=true) -> renders as "error"
  Flags(loading=true, empty=false, error=true, stale=false) -> renders as "error"

Sixteen combinations and four meanings, which means twelve of them are states the code can reach and nobody designed. They do not crash — the when has an ordering, so each resolves to something — and that is the problem: "loading with an error showing" renders as an error, silently, and the fact that a request is still in flight is lost. The sealed version has four constructors and stale lives inside Content, where it is the only place it can mean anything.

7. Scope is a design decision, not an implementation detail

Where a value lives is the question the state pillar spent eleven days on, and it keeps reappearing.

Hoist to the lowest common ancestor — the trigger is a second reader, not a rule to apply preemptively (Day 11). A ViewModel is for state that outlives the screen, not a default destination (Day 12). A CompositionLocal is for the environment, never for dependencies (Day 15). A theme scopes to a subtree; a component style scopes to one call, and using one mechanism for the other is why "danger button" can't be a CompositionLocal (Day 49).

8. Describe accurately and you get behaviour you didn't implement

Declaring a contentType on a text field gets you a working password manager (Day 54). Setting Role.Switch gets a correct screen-reader announcement. A LinkAnnotation gets link semantics. Merging a card's semantics correctly gets a better announcement, a simpler test selector and a single Switch Access target (Day 68).

This is the one I'd most want a reader to take away, because it inverts how accessibility usually gets framed. The semantics tree is the tree your tests query (Day 84) — so writing tests against text and descriptions forces the semantics to be right, and the accessibility work and the testing work turn out to be the same work.

What this method missed

Two things, and being honest about them matters more than the summary above.

Documentation order is not learning order. The docs are organised by API surface, so I wrote about pickers on Day 42 and about testing on Day 85 — even though a real project needs the testing setup first and may never need a date picker. Anyone learning Compose should read Days 5–17 (state and architecture), then 78–87 (performance and testing), then whichever component pillar their screen actually needs. The middle sixty days are reference material, not a curriculum.

Working from documentation means writing about the happy path. The docs describe what an API does; they rarely describe what it does at 2× font scale, in Arabic, on a three-year-old phone with a full memory cache. I tried to put those cases in every post — the @Preview(fontScale = 2f), the gesture-versus-button navigation check, the mid-range device — but they're the parts I had to supply, and they're where real bugs live. A series written from bug reports rather than from documentation would have a different and probably more useful shape.

There's a third thing I'd flag as a caveat rather than a failure: performance advice has a version. Day 79 laid out the stability model most Compose writing is based on, and Day 80 explained that strong skipping substantially changed it. A lot of the folklore in circulation — annotate every model class with @Immutable — is now unnecessary and occasionally harmful. If a technique's justification requires naming a compiler flag, check the flag before applying the technique.

The advice I'd actually give

If you're starting with Compose:

Learn the state model properly before anything else. Days 7 through 17. Everything else is easier afterwards, and skipping it produces code that works and can't be maintained.

Write the stateless content composable. Data in, callbacks out. It makes the component previewable (Day 92), testable (Day 85), reusable, and it's the single structural decision that most improves a Compose codebase.

Don't optimise until you've measured, and measure the phase. Most screens need none of Days 78–83. Performance work without a measured problem is complexity with a good reputation.

Do the accessibility pass as you build. Twenty minutes per screen while you're in it, five minutes thereafter. It's dramatically cheaper than an audit later, and it improves your tests for free.

Put the conventions in lint, not in a document. A convention that isn't enforced is a preference (Day 94).

Closing

The most useful sentence I can offer after a hundred days is the one Day 66 landed on and Day 83 repeated: the APIs are many, the ideas are few.

Compose looks large because its surface is large — hundreds of composables, dozens of modifiers, four animation APIs, three inset groups. But nearly every one of them is one of the eight ideas above, expressed for a particular case. Once the idea is clear, the API stops needing to be memorised, because you can usually guess what it should be called and what parameters it should take.

That's what a hundred days of reading documentation actually bought: not a memorised library, but a small enough model that the library became guessable.

Thanks for reading. The whole series is indexed at blogs.mobilebytesensei.com, and Day 1 starts at the beginning.


Day 100 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Jetpack Compose documentation.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS