← Back to books

Chapter 1: Why Compose

The Declarative Shift

If you have written Android UI before this book, you already carry a set of habits. You inflate a layout, you find views by ID, you wire up listeners, and from that point on you spend your time reaching into the view hierarchy and mutating it: set this text, hide that spinner, enable this button, update that adapter. The UI is an object you hold a reference to and poke at over time.

Jetpack Compose asks you to drop that habit almost entirely. Instead of holding references to views and mutating them, you write functions that describe what the screen should look like for a given piece of data. When the data changes, you don't go and update the screen yourself — you change the data, and the framework figures out what on screen needs to change.

That sentence is the whole book in miniature. Everything else — layouts, state, animation, custom drawing, testing, migration — is built on top of that one idea. So before we install anything or write a single @Composable, it's worth slowing down and understanding why the shift exists, what problem it solves, and what it costs you. If you understand the "why" deeply now, the rest of Compose will feel like a series of obvious consequences rather than a pile of new APIs to memorize.

This chapter assumes you know nothing about Compose. It does assume you can read Kotlin and that you've at least seen an Android Activity and an XML layout. By the end you'll understand the mental model that makes Compose tick, you'll have seen the same screen written both the old way and the new way, and you'll have an honest picture of where Compose stands as a production technology in 2026.


The world you're coming from: imperative UI

Let's make the "old way" concrete, because you can't appreciate the shift until you feel the pain it removes.

Here is about the simplest stateful screen imaginable: a number and a button that increases it. In the classic View system, the UI lives in XML:

<!-- res/layout/activity_main.xml -->
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center">

    <TextView
        android:id="@+id/countText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/incrementButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Increment" />

</LinearLayout>

And the behavior lives in the Activity:

class MainActivity : AppCompatActivity() {

    private var count = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val countText = findViewById<TextView>(R.id.countText)
        val incrementButton = findViewById<Button>(R.id.incrementButton)

        // Initial render
        countText.text = "Count: $count"

        incrementButton.setOnClickListener {
            count++
            // We have to manually push the new state into the view
            countText.text = "Count: $count"
        }
    }
}

Look closely at the line countText.text = "Count: $count". It appears twice — once to set the initial value, and once inside the click listener to keep the screen in sync after the value changes. That duplication is not an accident of this example. It is the defining characteristic of imperative UI: your application state (count) and your on-screen state (countText.text) are two separate things, and you are personally responsible for keeping them in agreement, everywhere, forever.

For a counter, that's trivial. The trouble is that real screens are not counters. Consider a screen that loads data from the network and can be in one of three situations: loading, error, or showing content. In the View world you typically have three (or more) views layered in the same space and you toggle their visibility:

fun render(state: UiState) {
    progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE

    errorText.visibility = if (state.error != null) View.VISIBLE else View.GONE
    errorText.text = state.error ?: ""

    val showContent = !state.isLoading && state.error == null
    contentGroup.visibility = if (showContent) View.VISIBLE else View.GONE
    if (showContent) {
        adapter.submitList(state.items)
    }
}

This is where imperative UI quietly rots. A few problems are hiding in plain sight:

  • You can produce invalid screens. Nothing stops the spinner and the error text from being visible at the same time if you forget a branch. The "valid combinations" live only in your head and in the discipline of this one function.
  • You must remember to call render(). Every code path that mutates state has to also remember to re-run the update. Miss one, and the screen silently shows stale data — one of the most common and most annoying Android bugs.
  • The cost grows with the screen. Add a "pull to refresh" indicator, an empty state, a retry button, and the number of visibility combinations you're manually juggling explodes. The function that keeps everything consistent becomes the most fragile code in your app.

None of this is a failure of the engineers who built the View system; it served Android well for over a decade. It is simply the nature of imperative UI: you give the framework a sequence of mutation instructions, and correctness depends on you issuing exactly the right instructions, in the right order, every single time the world changes.


The shift: describe, don't mutate

Now the same counter in Compose:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

Read that and notice what's missing. There is no findViewById. There is no second place where we push count into the text. There is no listener that updates the UI by hand. We wrote a function that says, in effect: "for whatever the current value of count is, the screen is a centered column containing the text Count: <count> and a button." The relationship between the data and the screen is stated once.

The line count++ inside onClick changes the data. It does not touch the Text. So how does the screen update? This is the heart of the model:

When state that a composable reads changes, Compose re-runs that composable to produce an up-to-date description of the UI, then efficiently updates only what actually differs on screen. This re-running is called recomposition.

You change count. Compose notices that Counter read count, re-invokes Counter, gets a new description ("Count: 1" instead of "Count: 0"), compares it to what's currently shown, and updates the one piece of text that changed. You never wrote the update. You described the destination and let the framework find the route.

Now revisit the three-state screen. In Compose it stops being a visibility-juggling problem and becomes an ordinary when:

@Composable
fun Screen(state: UiState) {
    when {
        state.isLoading      -> LoadingSpinner()
        state.error != null  -> ErrorMessage(message = state.error)
        else                 -> Content(items = state.items)
    }
}

The invalid combinations are simply gone. You cannot show the spinner and the error at the same time, because the when picks exactly one branch. There is no render() to remember to call, because there is nothing to manually sync — change state and the right branch appears. The set of valid screens is no longer a fact you maintain by discipline; it's enforced by the structure of the code.

That is the declarative shift. Imperative UI is a list of mutations you perform on a long-lived hierarchy. Declarative UI is a function from your current state to a description of the screen.


The mental model in one line

If you remember nothing else from this chapter, remember this:

UI = f(state)

Your screen is a function of your state. Give the function the same state, and you get the same screen. Change the state, and the framework runs the function again to get the new screen. Composables are that function, written in pieces.

A few consequences fall directly out of this idea, and they explain rules you'll meet throughout the book:

Composables describe, they don't return. A function like Counter() doesn't return a UI object you then attach somewhere. It emits UI into the composition as a side effect of being called, and its return type is Unit. That's why composables are verbs of structure ("here is a Column, inside it a Text") rather than factories that hand you a widget.

Composables should be free of side effects. Because Compose may call your composable many times (every recomposition), and may even skip or reorder calls for performance, a composable should not, for example, fire a network request or write to a database directly in its body. The function's job is to describe the screen for the current state — nothing more. Compose provides dedicated tools for "do this thing once" or "do this when that changes," and we'll cover them carefully in Part III. For now, just internalize that a composable is a description, and descriptions shouldn't have side effects.

The same state must produce the same screen. Two calls with identical inputs should describe identical UI. This is what lets Compose treat your function as something it can re-run freely, skip when inputs haven't changed, and reason about. It's the property that makes the whole engine efficient.

You don't need to fully understand recomposition, state, or skipping yet — those each get their own chapter. What matters right now is the posture: you are no longer a UI operator issuing commands to a view tree. You are an author describing what each state looks like, and the framework is the operator.


"But how is re-running a function fast?"

This is the first objection every experienced Android developer raises, and it's a good one. If changing a counter re-runs my function, and that function describes an entire screen, am I not redrawing the world on every keystroke?

No — and understanding why will make you trust the model.

Compose separates describing the UI from rendering it. Re-running a composable produces a lightweight, in-memory description of what the UI should be. Compose then diffs that description against the previous one and applies only the minimal real changes to what's actually on screen. Updating a Text whose string didn't change costs nothing; only the text that actually differs gets touched.

On top of that, Compose is aggressive about skipping. It tracks which composables read which pieces of state. If a piece of state changes, only the composables that actually read it are scheduled to re-run; everything else is left alone. Change count, and only the Text that reads count is affected — the Button, the Column, and the rest of your app don't re-run at all.

This is not a theoretical promise. After several years of focused performance work, Compose's scrolling benchmarks now match the equivalent View-based implementations, and recent releases ship runtime improvements (such as pausable composition during lazy prefetch) that reduce jank under heavy UI workloads. The declarative model is not a tax you pay for nicer code; on modern Compose it is genuinely competitive with the imperative system it replaces.

We'll return to performance in real depth in Part XI — including the cases where you can write slow Compose code and how to find and fix it. The point here is narrower: "re-run a function to update the UI" sounds expensive, but the architecture is specifically designed so that it isn't.


What you gain, concretely

The declarative shift is not just intellectually tidier. It changes day-to-day engineering in ways you'll feel within your first week:

Less glue, fewer wiring bugs. No findViewById, no view binding, no adapters wired to view holders, no listeners that mutate views. Whole categories of "the UI didn't update" and "the UI is in a weird state" bugs simply cannot occur, because there's no manual sync to get wrong.

State has one home. Because the screen is derived from state, the state becomes the single source of truth. This dovetails with modern Android architecture (a unidirectional flow of state down and events up), and we'll lean on it constantly from Part III onward.

Composition over inheritance. You build UI by calling smaller composables from larger ones — functions calling functions. Reuse is just extracting a function. Compare that to custom Views, where reuse often meant subclassing a ViewGroup and fighting its lifecycle.

One language, one place. Layout and logic both live in Kotlin, side by side. You stop context-switching between XML and code, and you stop maintaining two parallel descriptions of the same screen. Your IDE understands all of it as ordinary Kotlin: refactor, navigate, and find-usages all just work.

Powerful things become ordinary. Animations, theming, lists, and adaptive layouts are expressed in the same descriptive style as everything else. As you'll see in later chapters, animating a value or reacting to window size is no longer a special subsystem — it's just more state flowing through the same model.

It's worth being honest that there are also costs, because no shift is free. There's a real learning curve precisely because the model is different from what you know — your imperative instincts will fight you for a while. Some Android APIs and third-party libraries are still View-first and need an interop bridge. And it is genuinely possible to write Compose that recomposes too much and stutters, in ways that require understanding the engine to diagnose. This book is structured so that none of those costs ambush you: interop has its own chapters, performance has its own part, and the mental model we're building now is exactly what makes the failure modes legible.


Where Compose stands today (2026)

When a technology is new, "should I adopt it?" is a fair question. Compose is no longer new, and the answer has settled. Here's an honest snapshot as of this writing.

It's mature. Jetpack Compose reached its 1.0 stable release in mid-2021, which makes it close to five years old as a production toolkit. The core libraries are well past the point where you should worry about fundamental churn; the April 2026 release shipped core modules at version 1.11, distributed through the Compose Bill of Materials (BOM 2026.04.01), with a 1.12 line already in beta. Google's own guidance, internal apps, and a large share of the Android ecosystem build new UI in Compose by default. It is the recommended way to build Android UI.

The ecosystem is filled in. The pieces you need for a real app exist and are stable: Material 3 (including the newer "Expressive" direction) for components and theming, first-class navigation (with a next-generation navigation stack, "Nav3," maturing alongside the established one), image loading via libraries like Coil, robust state and lifecycle integration, and a mature testing story. Shared-element transitions — the kind of polished screen-to-screen animation that used to be painful — are now a stable, first-class API.

Adaptive and modern UI is a focus, not an afterthought. Compose has strong support for building one UI that adapts across phones, foldables, tablets, and larger screens — window size classes and multi-pane scaffolds are stable and production-ready. The most recent releases push further into declarative environment adaptation with new APIs (a mediaQuery system for reacting to device capabilities and posture, and new Grid, FlexBox, and Styles layout primitives). Several of these newest APIs are still marked experimental and may change before they stabilize, so we'll treat them as "where Compose is clearly heading" and flag their status wherever they appear. But the direction is unambiguous, and the adaptive foundations you'll build on are solid.

It's beyond Android. Through Compose Multiplatform, the same UI model runs on iOS, desktop, and web, sharing UI code across platforms from a single Kotlin codebase. Even if you only target Android, this matters: it signals long-term investment, and it means the skills you build in this book travel well beyond a single platform.

The practical takeaway: in 2026, choosing Compose for new Android UI is the default, low-risk decision. The interesting question for most teams is no longer "should we use Compose?" but "how do we move our existing XML screens to it without rewriting the app overnight?" — which is exactly why this book devotes an entire in-depth part to migration, covering interop and the full range of real-world scenarios rather than pretending everyone starts from a blank project.

A fair counterpoint, so you have the full picture: if you maintain a large, stable, View-based app that isn't actively changing, there is no rule that says you must migrate. Compose interoperates with Views in both directions precisely so you can adopt it incrementally, screen by screen or even component by component, on your own schedule. Adoption is a gradient, not a switch — and this book supports you wherever you sit on it.


How this book approaches Compose

A few words on method, so you know what to expect from the chapters ahead.

We start from zero on Compose, not from zero on programming. This book assumes you're comfortable reading Kotlin — functions, lambdas, classes, val/var — and that you've encountered basic Android concepts like an Activity. It does not assume you've ever written a composable. Chapter 4 specifically revisits the handful of Kotlin features (trailing lambdas, scope functions, higher-order functions) that Compose leans on most heavily, so if any of the snippets in this chapter felt slightly unfamiliar in their syntax, that's expected and we'll close the gap.

We build understanding before APIs. It's tempting to race to a screen full of buttons. But Compose rewards understanding the model — composition, state, recomposition — over memorizing component names. We'll always anchor new components in the underlying ideas, so you can reason about new APIs (including ones released after this book) rather than only recognizing the ones you've seen.

We pin to a known version. Compose ships frequently. To keep examples reproducible, the book targets a specific Compose BOM and notes when an API is stable versus experimental. Chapter 2 sets up your environment against that baseline. When you build real apps, you'll pin the BOM the same way, and upgrade deliberately.

Examples are real, not toy-shaped. Where a concept benefits from a complete, runnable example — a custom layout, an animation, a migrated screen — you'll get the whole thing, not a fragment that "you can figure out the rest of." The later parts in particular (canvas, animation, custom layout, migration) are built around code you can run and modify.


Summary

The single most important idea in this book is the declarative shift. In the View system, your UI is a long-lived hierarchy of objects that you hold references to and mutate over time; correctness depends on you manually keeping the screen in sync with your data, everywhere it changes. In Compose, your UI is a function of your state — you describe what each state looks like, change the state, and let the framework re-run your description and update only what differs.

We saw the same counter written both ways and watched the manual sync disappear. We saw a three-state screen stop being a visibility-juggling problem and become an ordinary when, with invalid combinations made unrepresentable. We distilled the model to UI = f(state) and drew out its consequences: composables describe rather than return, they should be free of side effects, and the same state must always yield the same screen. We addressed the natural "isn't re-running a function slow?" objection by separating description from rendering, and noted that modern Compose performs on par with the View system it replaces. And we took an honest look at where Compose stands in 2026: mature, recommended, broadly supported, expanding beyond Android, and designed for incremental adoption so that migration is a gradient rather than a leap.

You don't yet know how remember, mutableStateOf, or recomposition work under the hood — and that's fine. You have the posture: you are an author of state-to-screen descriptions, not an operator of a view tree. Everything else is detail built on that.

What's next

In Chapter 2, we leave concepts behind and get your hands dirty. We'll set up Android Studio for Compose, understand the role of the Compose compiler (now part of Kotlin itself) and the BOM, configure a project against the version this book targets, and run your very first composable with a live preview. By the end of the next chapter you'll have a working environment and a screen on your emulator — and from there, every chapter adds one more layer to the model you just learned.