Jetpack Compose: Modern Declarative UI Development on Android
My first Jetpack Compose screen took an afternoon to build and almost a week to stop fighting. I kept reaching for findViewById, kept hunting for the line that hid a view, kept wondering where the update code lived. Then it clicked: there is no update code. You describe what the screen looks like for a given state, and the framework handles the rest. That single shift is the whole story of declarative UI on Android, and once it lands, going back to XML layouts and manual view updates feels like hand-writing letters after years of email.
This guide is the one I wish someone had handed me on day one. It covers composables, state, layouts, side effects, navigation, testing, and a realistic migration path from the old view system. Every section includes real code you can paste into Android Studio and run. You do not need prior Compose experience, but you should be comfortable with Kotlin basics: functions, lambdas, and data classes. By the end you will have a mental model that holds up on a real project, not just in a tutorial.
Compose is no longer the new kid. Google recommends it for new screens, the Material 3 library is built on it, and most new Android samples ship in Compose first. That said, you do not have to rewrite your app to benefit. Compose lives happily next to XML views, one screen or one component at a time. The trade-off is real — there is a learning curve, and debugging recomposition takes practice — but the payoff is screens that are shorter, easier to test, and far less likely to drift out of sync with their data.
What Is Jetpack Compose and Why Declarative UI Wins on Android
Jetpack Compose is Android’s modern toolkit for building native UI in Kotlin. Instead of inflating XML layouts and then mutating views in code, you write composable functions that emit UI. When the data changes, Compose re-runs the functions that read that data and updates only the pixels that need to change. The framework compares the new description of the screen with the old one and applies the smallest possible update. You never write code that says “hide this, show that, set this text” after every event.
The old view system is imperative: you tell the phone what to do, step by step. Tap a button, find the text view, change its text, toggle a progress bar, remember to reset it on error. Miss one step in one code path and the screen lies about the data. I have shipped that bug more than once — a loading spinner that never stopped because an error branch forgot to hide it. In Compose, the spinner is visible when the state says loading and gone when it does not. There is no branch to forget, because the visibility is derived from the state every single time the screen draws.
That model also changes how you think about bugs. When a Compose screen looks wrong, the question is almost always “what is the state?” rather than “which update did I miss?” You inspect one source of truth — usually a ViewModel — and the screen follows. Debugging becomes tracing data instead of tracing a sequence of mutations scattered across click listeners and callbacks. In practice, this is the biggest productivity win, bigger than the shorter code.
Compose is a Kotlin-first toolkit, which matters more than it sounds. Layouts are code, so you get loops, conditions, and function extraction for free. A list of settings rows is a for loop over a data list, not ten copies of the same XML block. A reusable card is a function with parameters, not a custom view class with three constructors and an attribute set. Android Studio previews render composables without launching the app, which shortens the edit-and-check loop to seconds.
Declarative UI vs Imperative Views: A Side-by-Side Comparison
The difference is easiest to see in a loading screen. In the view system you would keep references to a progress bar, an error text, and a content list, then write a function that toggles each one for loading, success, and error states. In Compose you write one when expression over a state object and each branch emits the right UI. The Compose version is shorter, and — here is the catch — it is also harder to get wrong, because adding a fourth state later means adding a fourth branch in one place instead of updating three toggle sites.
| Concern | XML view system | Jetpack Compose |
|---|---|---|
| UI definition | XML layout files plus Kotlin code | Kotlin composable functions only |
| Updating the screen | Manual view mutation after each event | Automatic recomposition when state changes |
| State ownership | Spread across views, fragments, and fields | Single source of truth, usually a ViewModel |
| Reuse | Custom views or include tags | Plain functions with parameters |
| Lists | RecyclerView plus adapter plus ViewHolder | LazyColumn with a simple items block |
| Theming | Styles and themes in XML resources | MaterialTheme in Kotlin, applied everywhere |
| Preview | Layout preview in the editor | Composable preview with multiple states |
None of this makes the view system useless. XML layouts still power millions of screens, and Compose interoperates with them by design. The comparison is about where new work should go. For a new screen in 2026, Compose is the default choice, and the rest of this guide assumes you are building that way.
Jetpack Compose Composable Functions: The Building Blocks of Every Screen
A composable is a Kotlin function annotated with @Composable. It emits UI by calling other composables: Text for a label, Button for an action, Image for a picture, Row and Column for arrangement. Functions compose into a tree that mirrors the visual hierarchy, and because they are ordinary Kotlin functions, you can extract, parameterize, and test them like any other code. A screen is just a composable that calls smaller composables.
Here is the smallest useful example. It shows a greeting and a button inside a column with padding:
@Composable
fun Greeting(name: String, onContinue: () -> Unit) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Hello, " + name)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onContinue) {
Text("Continue")
}
}
}
Three ideas in that snippet carry the whole toolkit. First, composables take data as parameters and report events upward through lambdas like onContinue. Second, modifiers attach layout, spacing, and behavior to an element without changing its type. Third, the function has no return value and no side effects — it only describes UI. Keep your composables pure like this and most Compose problems disappear before they start.
Modifiers deserve a closer look because they do so much work. Modifier.padding adds space, Modifier.fillMaxWidth claims the full width, Modifier.clickable adds tap handling, Modifier.background paints a color. Modifiers chain in order, and order matters: padding then background paints behind the padding, background then padding paints inside it. I learned that the hard way when a card’s background color kept covering its own padding. Chain modifiers in the order you mean, and read the chain left to right as a sentence about the element.
Writing Small Reusable Composables That Stay Easy to Test
Split large screens into small composables, each handling one clear visual job. A profile screen becomes a ProfileHeader, a StatsRow, and a SettingsList. Small composables are easier to preview, easier to reuse on another screen, and easier to test because you can feed them fake data and check what they draw. My rule of thumb: if a composable scrolls past one screen in the editor, it is doing too much.
Keep composables stateless where you can. A stateless composable receives everything it displays as parameters and reports every interaction through callbacks. It never reads a database, never holds a network client, never keeps a hidden counter. All of that lives higher up, in the screen or the ViewModel. Stateless composables are boring in the best way: same inputs, same output, every time.
Preview Annotations: Seeing Your UI Without Running the App
The @Preview annotation renders a composable in Android Studio without launching an emulator. Add previews for the states that matter — loading, empty, error, long text, dark theme — because those are the states that reveal layout problems. A preview with a fake view model state costs five lines and saves a dozen emulator launches. I keep a preview next to every screen-level composable, and I check the dark-theme preview before every release because colors that look fine in light mode regularly fail in dark mode.
Jetpack Compose State Management: remember, mutableStateOf and State Hoisting
State is any value that changes over time and affects what the screen shows: a text field’s content, a selected tab, a list of search results, a loading flag. In Compose you hold state in observable holders — remember plus mutableStateOf for local UI state, or a ViewModel exposing StateFlow for screen state. When the value changes, Compose schedules recomposition of every composable that read it, and nothing else. That targeting is what keeps Compose fast.
A counter is the classic first example, and it is worth typing out because it shows the whole pattern:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Tapped " + count + " times")
Button(onClick = { count++ }) {
Text("Tap me")
}
}
}
remember keeps the value alive across recompositions, and mutableStateOf notifies Compose when it changes. Without remember, the counter would reset to zero on every recomposition. Without mutableStateOf, Compose would never know the value changed and the text would never update. The two always travel together for local state. For anything that must survive rotation or a process death, move the state up into a ViewModel instead.
State hoisting is the pattern of lifting state to the component that owns it and passing values down and events up. The child receives a value and a callback, never the mutable holder itself. A search field, for example, receives the query string and an onQueryChange callback from its parent. The parent owns the query, the child only displays it. This makes the child reusable — the same search field works on two screens — and testable, because you can drive it with fixed inputs.
State Hoisting in Practice: Who Owns the Data
Hoist state to the lowest common owner of everyone who needs it. Two siblings that share a selection need their parent to own it. A whole screen’s data belongs in the ViewModel. Local UI state that nobody else needs — whether a dropdown is open, whether a dialog is showing — can stay in the composable with remember. The mistake I see most often is hoisting too little: state buried inside a child that a sibling needs, fixed later with a callback spaghetti that is harder to untangle than hoisting was in the first place.
For screen-level state, expose it from the ViewModel as a StateFlow and collect it in the composable with collectAsStateWithLifecycle. The lifecycle-aware collection stops updates when the screen is in the background, which prevents wasted work and crashes from updating a screen that has gone away. You can study the background patterns behind this in coroutines and Flow for background tasks, because flows power much of modern Compose state handling, and the same cancellation rules apply.
@Composable
fun ArticleScreen(viewModel: ArticleViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is Loading -> CircularProgressIndicator()
is Error -> Text(text = "Something went wrong: " + state.message)
is Success -> ArticleList(articles = state.articles)
}
}
Notice how the loading, error, and success branches live in one when expression. There is no code path that can show a spinner and a list at the same time, because the state can only be one thing at a time. That is a sealed interface doing quiet, reliable work — the compiler will even warn you if you add a fourth state and forget a branch.
Common State Mistakes That Cause Extra Recomposition
Reading state high in the tree recomposes everything below it. If a text field’s value is read at the top of a screen, every keystroke recomposes the whole screen, including a long list that does not depend on the text. The fix is to read state as low as possible: pass the value down and let only the text field read it. A second mistake is creating state inside a list item with remember and then wondering why scrolling resets it — LazyColumn recycles items, so item state belongs in the list’s data or in the ViewModel, not in the item composable.
Jetpack Compose Layouts: Row, Column, Box and LazyColumn Lists
Three layouts cover most screens. Row arranges children horizontally, Column arranges them vertically, and Box stacks children on top of each other for overlays and centered content. Each accepts alignment and arrangement options that control spacing without nested containers. A settings row is a Row with an icon, a text, and a switch, spaced with Arrangement.SpaceBetween. A form is a Column with spaced fields. A profile picture with a badge is a Box with the badge aligned to the bottom end. Simple rules, predictable results.
Modifiers do the fine work inside layouts. Modifier.weight inside a Row or Column lets a child claim a share of the remaining space — a search field that takes all free width next to a fixed-size button, for example. Modifier.fillMaxSize, fillMaxWidth, and fillMaxHeight claim space from the parent. Modifier.size sets exact dimensions. The mental model is simple: the parent measures children, children report their sizes, and the parent places them. When a layout looks wrong, check the modifiers first; the layout itself is rarely the problem.
Lists deserve their own layout. LazyColumn only composes the items currently visible on screen, which keeps a list of a thousand entries fast and memory-light. RecyclerView did the same job with an adapter, a ViewHolder, and a diff callback. LazyColumn needs a list and an items block:
@Composable
fun ArticleList(articles: List<Article>) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(articles, key = { it.id }) { article ->
ArticleRow(article = article, onClick = { })
}
}
}
The key parameter matters. It tells Compose which item is which when the list changes, so an item that moves keeps its state and animates instead of flickering. Use a stable id from your data, never the list index — indices shift when items are added or removed, and a shifting key produces exactly the kind of wrong-item bug that is painful to chase. LazyRow is the horizontal twin, and LazyVerticalGrid handles grids with the same lazy behavior.
Row and Column Arrangement: Spacing Without Nested Containers
Arrangement controls how children share space along the main axis: Arrangement.spacedBy adds a fixed gap between children, SpaceBetween pushes the first and last child to the edges, SpaceEvenly distributes the gaps. Alignment controls the cross axis: a Row aligns children vertically, a Column aligns them horizontally. Learn these two options and you will stop wrapping layouts in extra containers to get spacing right. Nested containers were the XML way; in Compose, arrangement options replace most of them.
LazyColumn Performance: Keys, Content Types and Item Reuse
Long lists with mixed item types — headers, ads, cards — benefit from the contentType parameter, which lets Compose reuse the right slot for each type instead of recomposing a card layout into a header layout. Keep item composables light: heavy work inside an item runs every time it scrolls into view. And never put a LazyColumn inside a vertically scrolling Column without a fixed height; the list cannot measure itself inside an unbounded parent, and the error message will tell you so in plain language.
Material 3 components sit on top of these layouts and give you buttons, cards, text fields, navigation bars, and dialogs that follow current design guidance and adapt to light and dark themes. Use them before building custom controls — users recognize the behavior, accessibility comes free, and your screens stay consistent. Themes define colors, typography, and shapes in one place, so a brand change updates every screen at once. You can plan that theming work alongside responsive layout thinking in building responsive layouts for phones tablets and foldables, because the same breakpoints and adaptive ideas apply to Compose layouts on phones, tablets, and foldables.
Side Effects in Jetpack Compose: LaunchedEffect, rememberCoroutineScope and DisposableEffect
Composable functions can run many times, at moments you do not control. Anything that should happen once, or at a specific lifecycle moment, belongs in a side effect handler — never in the body of a composable. A network call in a composable body fires on every recomposition. A listener registered there registers again and again. I have watched a screen fire the same analytics event forty times because the call sat in the drawing code. Side effect APIs exist to stop exactly that.
LaunchedEffect runs a suspend block when its key changes, and cancels it when the key changes again or the composable leaves the composition. It is the right tool for one-shot work tied to a value: loading data when a screen id changes, showing a snackbar when an error appears. rememberCoroutineScope gives you a scope to launch coroutines from event handlers like button clicks, where LaunchedEffect does not fit because nothing has changed — the user just tapped something. DisposableEffect registers something that needs cleanup, like a listener or a broadcast receiver, and its onDispose block runs when the composable leaves.
@Composable
fun ArticleDetail(articleId: String, viewModel: DetailViewModel) {
LaunchedEffect(articleId) {
viewModel.loadArticle(articleId)
}
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { snackbarHostState.showSnackbar("Saved") }
}) {
Text("Save article")
}
}
The key is the whole design. LaunchedEffect(articleId) re-runs only when the id changes, so navigating from one article to another reloads correctly, and rotating the phone does not reload at all. Change the key and you change the behavior — that is the lever to reach for when an effect runs too often or too rarely. A key of Unit runs once; a key of a changing value runs on every change.
When to Use LaunchedEffect vs rememberCoroutineScope
Use LaunchedEffect when the work should start because something changed or the screen appeared. Use rememberCoroutineScope when the work should start because the user did something. Mixing them up is a common source of double network calls: a LaunchedEffect with a key that changes on every recomposition behaves like a body call. If your effect fires more often than you expect, print the key and watch it change — the answer is usually there.
DisposableEffect and Cleanup: Listeners That Do Not Leak
Anything you register, you must unregister. DisposableEffect pairs the two: register in the effect body, unregister in onDispose. A lifecycle observer, a location listener, a connectivity callback — all of them belong here. The framework guarantees onDispose runs when the composable leaves the composition, which makes leaks a matter of forgetting the API rather than forgetting the cleanup site.
Jetpack Compose Navigation and Screen Structure with ViewModel
Navigation in Compose uses a NavController that manages a back stack of destinations, each a composable representing one screen. A navigation graph declares the flow — home to detail to settings — in one place, so the app’s structure stays visible to the whole team. Arguments pass data between destinations; pass identifiers, not whole objects, because the back stack serializes arguments and a large object in the route is a crash waiting for a process death to happen.
Bottom navigation, tabs, and drawers share the same controller and update their selection as the user moves. Keep navigation calls out of ViewModels — the ViewModel should expose an event like “article opened” and the composable should call navController.navigate. That separation keeps the ViewModel testable without a navigation dependency and keeps the screen’s flow readable in one file.
Structure each screen as two composables: a route that connects the ViewModel and a content composable that only draws from its inputs. The route collects state and forwards events; the content is pure and preview-friendly. A designer can open the content preview and see the screen with fake data, no ViewModel, no network, no emulator. That split is the single best structural habit in Compose, and it pays off from the first week.
Passing Arguments Safely Between Compose Destinations
Define arguments with types in the route — an article id as a string, a tab index as an integer — and read them in the destination’s ViewModel through SavedStateHandle. The handle survives process death, so a user who backgrounds the app and returns hours later still lands on the right article. Deep links use the same mechanism, which means one argument definition serves both in-app navigation and links from outside the app.
Migrating from XML Views to Jetpack Compose Step by Step
Migration does not mean a rewrite. Compose interoperates with the view system in both directions: ComposeView hosts Compose inside an XML layout or a fragment, and AndroidView hosts a classic view inside Compose for components like maps or a WebView that have no Compose equivalent yet. Start with a new screen or a self-contained component — a settings row, a card, a dialog — so the team learns the patterns on a small surface before touching the core flows.
The practical sequence I recommend: first, add the Compose dependencies and a theme bridge so colors match across both systems. Second, build one new screen entirely in Compose, with its own ViewModel and previews. Third, replace one small component inside an existing XML screen with a ComposeView and watch it in production for a release. Fourth, convert screens one at a time, starting with the simplest, and keep the navigation graph as the seam between old and new. A gradual migration protects users while the team builds real skill, and it lets you stop at any point with a working app.
class LegacyActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
Greeting(name = "Android", onContinue = { })
}
}
}
}
setContent replaces setContentView and makes the whole activity Compose. For a fragment, ComposeView does the same job inside onCreateView. Shared design tokens keep colors consistent during the transition, and your existing string and dimension resources still work from Compose code. Dark theme support deserves attention during the move, and you can review dark theme support with Material Design because Compose themes make dark mode straightforward — the same color roles, the same light and dark schemes, applied in one place instead of scattered across styles.
Interop Patterns: ComposeView Inside XML and AndroidView Inside Compose
When a Compose screen needs a classic view, AndroidView wraps it with a factory that creates the view and an update block that syncs it with Compose state. When an XML screen needs Compose, ComposeView hosts it with setContent. Both directions preserve the lifecycle and the theme. The rule is simple: use the interop view as a thin bridge, not as a permanent home — a bridge you cross once per component, then leave behind.
What to Migrate First and What to Leave Alone
Migrate new screens first, then simple lists and forms, then complex screens. Leave alone anything that works and rarely changes — a stable settings screen in XML costs nothing to keep, and rewriting it buys risk without benefit. Migrate a screen when you are already changing it for a feature; that way the migration rides on work that was happening anyway, and the feature’s tests cover the new code.
Jetpack Compose Testing, Performance and Common Mistakes
Compose testing uses dedicated tools that find nodes by text or content description, then perform clicks and text input in a controlled environment. Unit tests cover ViewModel logic and state transformations. UI tests verify that a given state produces the expected visible elements. Accessibility checks ride along for free when you use semantic descriptions — screen readers and tests read the same tree, so a test that finds a button by its description also proves a blind user can find it.
Performance work in Compose is mostly about recomposition scope: read state low in the tree, keep composables fast and free of side effects, and use keys in lazy lists. The Layout Inspector in Android Studio shows recomposition counts live — a composable flashing on every keystroke is a signal, not a mystery. Baseline Profiles and the Compose compiler’s stability reports handle the deeper cases: stable types skip recomposition when their inputs have not changed, unstable types recompose more often. Mark your data classes with @Immutable or @Stable when their contents genuinely never change, and the compiler rewards you.
Screenshot tests catch visual regressions across themes and font scales, but they need care: a test that fails on every device with a different font size is a test that gets ignored. Run screenshot tests on a fixed configuration and treat failures as real until proven otherwise. A balanced test plan — unit tests for logic, UI tests for key flows, screenshot tests for the screens that matter — gives confidence without slowing every small change to a crawl.
Five Mistakes Every Compose Beginner Makes
First, putting side effects in the composable body, which repeats network calls and listeners. Second, reading state at the top of the screen and recomposing everything on every keystroke. Third, forgetting keys in lazy lists and getting the wrong item’s state after a reorder. Fourth, using remember for data that must survive rotation instead of a ViewModel. Fifth, building giant composables that mix layout, logic, and navigation in one function. I have made all five. The fix for each is a pattern in this guide, and the patterns compound: small, stateless, well-keyed composables with state hoisted to the right owner.
Jetpack Compose FAQ: Questions Developers Ask Before Their First Screen
Is Jetpack Compose stable enough for production apps in 2026?
Yes. Compose has been stable since 2021, powers Google’s own apps, and the Material 3 library, navigation, and testing tools are all production-grade. Large apps run fully on Compose, and the interop story means you can adopt it without a rewrite. The remaining rough edges are in niche areas like complex custom layouts, where the view system still has deeper tooling — and even there, AndroidView bridges the gap.
How long does it take to learn Jetpack Compose if I know Kotlin and XML layouts?
Most developers build their first real screen in a day and feel comfortable within two to four weeks of daily use. The declarative model is the hard part, not the API — the functions are simple, but thinking in state instead of steps takes practice. Rebuilding one familiar screen from your own app is the fastest way through that phase, because you already know what the screen should do and can compare the two versions directly.
Can Jetpack Compose and XML views live in the same app?
Yes, and that is the designed migration path. ComposeView hosts Compose inside XML screens and fragments, AndroidView hosts classic views inside Compose, and both share themes, resources, and navigation. Many large apps run mixed for years. The mix is not a compromise to be embarrassed about; it is the official approach, and it lets each screen move when its team is ready.
Why is my Compose screen recomposing so many times?
Usually because state is read too high in the tree or an unstable type changes identity on every recomposition. Move the state read as low as possible, pass values down instead of reading them at the top, and check the Layout Inspector’s recomposition counts to find the hot spots. A list that recomposes on every keystroke in a search field is the classic symptom, and the fix is almost always to hoist the list data separately from the query state.
Do I still need a ViewModel with Jetpack Compose?
Yes, for anything beyond local UI state. The ViewModel survives configuration changes, owns screen state and business logic, and keeps composables pure and testable. Local state like whether a menu is open can live in remember, but data that comes from a repository, a network call, or a database belongs in the ViewModel. Compose without ViewModels works in demos and falls apart in real apps the first time a user rotates the phone.
Your First Compose Screen: A Next Step You Can Take Today
Open Android Studio, create a new project with the Compose template, and rebuild one screen you already know — your app’s settings screen, or a simple profile page. Write the state first as a data class, then the content composable, then the route that connects a ViewModel. Add previews for loading, empty, and error states, and run the Layout Inspector while you tap through it. That one exercise teaches more than a week of reading, because it forces every idea in this guide into a single working screen.
From there, the path is steady rather than steep: one new screen in Compose, one small component inside an old screen, one migration at a time. Keep composables small, hoist state to its rightful owner, keep side effects in their handlers, and let the framework do the updating. Declarative UI rewards that discipline with screens that stay in sync with their data, tests that stay readable, and a codebase that a new teammate can understand in a day instead of a month. The XML era built great apps; the Compose era builds them with less code and fewer ways to get out of sync.


