SuriDevs Logo
Jetpack Compose UI code on the left and the rendered Android screen on the right, illustrating Compose's declarative model

Jetpack Compose After 2 Years in Production: Honest Review

By Sagar Maiyad  May 26, 2026

I resisted Compose for about a year after it went stable. Had too many "1.0" frameworks burn me in the past. But eventually I tried it on a small project, and I haven't written a new XML layout since.

That doesn't mean Compose is perfect. After using it in production for two years now, here's my honest assessment — what genuinely makes Compose worth the switch (state-driven UI, lists, previews, animations), and where it still bites (build times, APK size, the learning curve nobody warns you about). The official Jetpack Compose documentation is the API reference; this post is the field report.

What this post covers (jump to any section):

What Compose Gets Right

No More UI Update Bugs

This is the big one. In XML-land, you have state in one place and UI in another. Keeping them in sync is your problem:

// The old way - state and UI are separate
class CounterActivity : AppCompatActivity() {
    private var count = 0
    private lateinit var textView: TextView

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

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

        button.setOnClickListener {
            count++
            textView.text = "Count: $count"  // Forgot this once. Bug took hours to find.
        }
    }
}

I can't count how many bugs I've fixed where someone updated state but forgot to update the UI, or updated the UI twice, or updated it with stale data.

Compose eliminates this entire category of bugs:

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

    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
    // UI updates automatically. No way to forget.
}

When count changes, the UI updates. There's no step to forget.

Lists Are So Much Simpler

RecyclerView is powerful but tedious. Every list needs an adapter, a ViewHolder, an XML layout, and careful management of view recycling. I've spent way too much of my career writing RecyclerView boilerplate.

The old way:

// XML layout file
// Adapter class with onCreateViewHolder, onBindViewHolder
// ViewHolder class with findViewById for each view
// DiffUtil callback for efficient updates
// Item decorations for spacing
// About 80-100 lines total for a simple list

Compose:

@Composable
fun UserList(users: List<User>) {
    LazyColumn {
        items(users) { user ->
            Column {
                Text(user.name)
                Text(user.email)
            }
        }
    }
}

That's it. Ten lines. Animation and efficient recycling are built in.

Everything Is Just Kotlin

No more switching between XML and Kotlin. No more learning which attributes go in XML vs code. No more app: vs android: namespace confusion.

Your entire UI is Kotlin functions. If you know Kotlin, you can read any Compose code. Styles, themes, animations, layouts - all the same language.

Previews That Actually Work

Android Studio's XML preview was always flaky. Half the time it wouldn't render, especially with custom views or complex layouts.

Compose previews work reliably:

@Preview(showBackground = true)
@Composable
fun UserCardPreview() {
    MaterialTheme {
        UserCard(User("John Doe", "[email protected]"))
    }
}

You can have multiple previews - light mode, dark mode, different data states - all visible at once. This changed how I develop UI. I actually use the preview now instead of running the app for every change.

Animations Are Easy

Animations in the View system required understanding multiple APIs (ObjectAnimator, ValueAnimator, LayoutTransition, MotionLayout). Most developers avoided animations because they were hard to get right.

Compose has simple animation APIs:

val alpha by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    label = "contentAlpha"
)
Box(modifier = Modifier.alpha(alpha)) {
    Content()
}

I add animations to things now that I never would have bothered with in XML.

Where Compose Still Struggles

The Learning Curve Is Real

I picked up Compose quickly because I already knew Kotlin and React. For developers coming from Java and XML, it's a bigger jump. The mental model is completely different.

Concepts that trip people up:

  • Recomposition: Understanding when and why your composable reruns
  • State hoisting: Knowing where state should live
  • Remember vs rememberSaveable: When each is appropriate
  • Side effects: LaunchedEffect, DisposableEffect, SideEffect - when to use which

Budget time for learning. Don't expect the team to be productive immediately.

Build Times

Compose compilation is slower than XML layouts. On my machine:

  • Clean build with Compose: noticeably longer
  • Incremental builds: usually fine
  • Large Compose codebases: can get slow

Kotlin 2.2.21 improved this with better configuration cache support. Still not as fast as pure XML, but the productivity gains outweigh the build time cost for me.

APK Size Increase

Compose adds to your APK:

  • Compose runtime: ~200-500 KB
  • Material 3: additional overhead
  • Compose compiler output tends to be larger than XML

For most apps this doesn't matter. If you're optimizing for APK size in emerging markets, measure the impact — and read Android optimized resource shrinking to recover most of that overhead with R8 and resource shrinking.

Some Libraries Haven't Caught Up

Most popular libraries work with Compose now, but occasionally you hit one that only provides XML views. You can use AndroidView to embed traditional views in Compose, but it's awkward.

Debugging Can Be Tricky

Debugging Compose is different from debugging Views. The Layout Inspector has improved, but understanding recomposition behavior sometimes requires reading compiler output or using the composition local inspector.

Performance Tuning Requires New Vocabulary

Once your app is more than a few screens, you'll run into "this list scrolls fine in debug but jank in release" or "this composable rebuilds 40 times per second." The fix is rarely architectural — it's almost always one of:

  • Unstable parameters force recomposition. Pass a List<Foo> instead of ImmutableList<Foo>? Every parent state change recomposes the child, even if the list is unchanged. The Compose compiler can't prove List is stable. Annotate your data classes with @Immutable (all fields are vals of stable types) or @Stable (you guarantee stability manually) so the compiler can skip recomposition when inputs are unchanged.
  • Lambdas allocate on every recomposition. Button(onClick = { viewModel.retry() }) creates a new lambda each time, defeating skip optimization. Hoist it: val onRetry = remember(viewModel) { { viewModel.retry() } }.
  • Reading state inside a Modifier chain triggers extra layout passes. Use the deferred-reading APIs (Modifier.offset { intOffset }, Modifier.graphicsLayer { ... }) for state that changes per-frame.

The recomposition tracking in Layout Inspector (Android Studio Hedgehog and later) shows you the count per composable — if you see a counter ticking up while nothing visible is changing, you've found your problem. The official Jetpack Compose performance docs cover the full toolkit.

This vocabulary doesn't exist in the XML world — there, you'd just say "the view is invalidating too often" and move on. Compose makes it more granular, which is good for performance but a learning tax.

Real-World Comparison: Same Feature, Both Approaches

Here's a user profile component in both systems:

XML + Activity:

// layout_user_profile.xml - 30 lines
// UserProfileActivity.kt:
class UserProfileActivity : AppCompatActivity() {
    private val viewModel: UserProfileViewModel by viewModels()
    private lateinit var nameText: TextView
    private lateinit var emailText: TextView
    private lateinit var avatarImage: ImageView
    private lateinit var loadingProgress: ProgressBar
    private lateinit var errorText: TextView
    private lateinit var retryButton: Button

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

        nameText = findViewById(R.id.nameText)
        emailText = findViewById(R.id.emailText)
        avatarImage = findViewById(R.id.avatarImage)
        loadingProgress = findViewById(R.id.loadingProgress)
        errorText = findViewById(R.id.errorText)
        retryButton = findViewById(R.id.retryButton)

        retryButton.setOnClickListener { viewModel.retry() }

        viewModel.state.observe(this) { state ->
            loadingProgress.visibility = if (state.isLoading) View.VISIBLE else View.GONE
            errorText.visibility = if (state.error != null) View.VISIBLE else View.GONE
            errorText.text = state.error
            retryButton.visibility = if (state.error != null) View.VISIBLE else View.GONE

            state.user?.let { user ->
                nameText.text = user.name
                emailText.text = user.email
                Glide.with(this).load(user.avatarUrl).into(avatarImage)
            }
        }
    }
}

Compose:

@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val error = state.error
    val user = state.user

    when {
        state.isLoading -> {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                CircularProgressIndicator()
            }
        }
        error != null -> {
            Column(
                modifier = Modifier.fillMaxSize(),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Text(error)
                Button(onClick = { viewModel.retry() }) {
                    Text("Retry")
                }
            }
        }
        user != null -> {
            Column(modifier = Modifier.padding(16.dp)) {
                AsyncImage(
                    model = user.avatarUrl,
                    contentDescription = null,
                    modifier = Modifier.size(100.dp).clip(CircleShape)
                )
                Text(user.name, style = MaterialTheme.typography.headlineSmall)
                Text(user.email)
            }
        }
    }
}

A couple of details worth calling out:

  • hiltViewModel() (from androidx.hilt:hilt-navigation-compose) is the correct factory for @HiltViewModel-annotated classes. Plain viewModel() uses the default factory and will throw at runtime as soon as your ViewModel has injected dependencies.
  • collectAsStateWithLifecycle() (from androidx.lifecycle:lifecycle-runtime-compose) is the officially recommended collector since Lifecycle 2.6 — it pauses collection when the composable's lifecycle drops below STARTED, which collectAsState() does not.
  • Capturing state.error and state.user into local vals before the when lets Kotlin smart-cast them, so you don't need !! non-null assertions to access fields inside the branches.

The Compose version is shorter, but more importantly, it's clearer. The when/else structure makes the states obvious. No visibility toggling scattered throughout.

Should You Use Compose?

Short answer: yes, for almost any new Android UI work. Compose is the recommended toolkit, has full library ecosystem support, and the productivity gains outweigh its costs (APK size, build time, learning curve) for the vast majority of apps. The cases below are where XML still makes sense.

Use Compose for:

  • New projects
  • New features in existing apps
  • Teams comfortable with Kotlin
  • Apps where developer velocity matters more than APK size

Stay with XML for:

  • Apps already working fine with XML
  • Teams with limited Kotlin experience
  • Apps targeting extremely low-end devices where you need every byte
  • Cases where you need libraries that don't support Compose

Hybrid approach:
You can mix them. I have apps with Compose screens and XML screens coexisting. Use ComposeView to add Compose to XML, or AndroidView to add XML views to Compose. The official Compose-to-Views interop guide covers both directions in detail.

Migration Strategy

If you're migrating an existing app:

  1. Don't rewrite everything at once. That's how projects fail.
  2. Start with new screens. Write new features in Compose.
  3. Migrate leaf screens first. Simple screens with no shared components.
  4. Create Compose versions of shared components. As you need them.
  5. Migrate complex screens last. When you're comfortable.

I migrated one app over 6 months, screen by screen, while shipping features. Much less risky than a big-bang rewrite. Navigation was one of the first things I migrated — I wrote about the full setup with type-safe routes, nested graphs, and bottom navigation in the Jetpack Compose Navigation guide. If you're ready to upgrade, I wrote about how to migrate from Navigation 2 to Navigation 3 with multiple back stacks and type-safe data class routes.

Patterns That Work Well in Compose

State hoisting:

// Lift state up so composables are reusable
@Composable
fun SearchBar(
    query: String,
    onQueryChange: (String) -> Unit
) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Parent controls the state
@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }
    SearchBar(query = query, onQueryChange = { query = it })
}

Using remember correctly:

// Cache expensive calculations
val sortedItems = remember(items) {
    items.sortedBy { it.name }  // Only recalculates when items changes
}

// Store state across recomposition
var expanded by remember { mutableStateOf(false) }

Side effects:

@Composable
fun UserScreen(userId: String, viewModel: UserViewModel) {
    // Load data when userId changes
    LaunchedEffect(userId) {
        viewModel.loadUser(userId)
    }

    val user by viewModel.user.collectAsStateWithLifecycle()
    // UI
}

Final Thoughts

I'm not going back to XML for new development. The productivity gains are too significant. But I also don't regret learning the View system - that knowledge helps when debugging Compose internals or integrating legacy code.

If you're on the fence, try building one screen in Compose. That's what convinced me. The code is more readable, the development experience is better, and once you internalize the mental model, you'll wonder why you ever wrote XML layouts.

Just budget time for the learning curve. It's not trivial.

One more area where Compose pulls ahead of XML: localization. If you've ever fought Activity.recreate() to switch app language at runtime, take a look at changing app language in Jetpack Compose with the Android 13 LocaleManager API — it's dramatically cleaner than the old approach.

Frequently asked questions

Is Jetpack Compose worth learning in 2026?

Yes — Compose is now the recommended UI toolkit on Android, every Google sample is Compose-first, and the major libraries (Navigation, Hilt, Paging, CameraX, Material 3) all ship first-class Compose support. The mental model takes a few weeks to internalize if you're coming from XML and Views, but once you do, you stop writing UI bugs that came from state-and-view drift. The only valid reason not to learn it in 2026 is if you maintain a legacy app that won't migrate — and even then, knowing Compose helps you read modern Android documentation.

Should I migrate my existing XML app to Jetpack Compose?

Don't rewrite — migrate incrementally. The safest path is: write all new screens in Compose, use ComposeView to add Compose to existing XML screens where it helps, and migrate leaf screens (settings, about, simple lists) before complex ones. A full rewrite typically takes 3-6× longer than estimated and ships zero user-visible value during that time. A 6-month incremental migration while shipping features is much less risky and lets the team learn Compose under real conditions.

How long does it take to learn Jetpack Compose?

For a Kotlin-fluent Android developer: about 2 weeks to be productive on simple screens, 1-2 months to handle complex state and side effects confidently, and 3-6 months to deeply understand recomposition behavior and performance tuning. Developers coming from Java need to learn Kotlin first, which doubles the timeline. The biggest conceptual jumps are state hoisting (where state should live), the difference between remember and rememberSaveable, and choosing the right side-effect API (LaunchedEffect vs DisposableEffect vs SideEffect).

Does Jetpack Compose increase APK size?

Yes — typically by 200-500 KB for the Compose runtime, plus more if you use Material 3 components. For most apps this is irrelevant; the difference is smaller than a single low-res banner image. It matters in two cases: apps targeting emerging markets where 50 MB matters, and instant apps where the size limit is hard. R8 minification and resource shrinking offset some of the cost — see Android optimized resource shrinking for techniques that recover most of that overhead.

Android Jetpack Compose

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Related Posts

Latest Tags