Skip to main content
Compose for Modern UI

Designing with Compose: A Professional's Approach to Modern UI Craft at Artnest

Building user interfaces with Jetpack Compose feels different from the old View system. Not just because the code looks different, but because the entire mental model shifts. We've been working with Compose since its early betas, and we've seen teams struggle with the same questions: when does declarative UI actually save time, and where does it create new problems? This guide answers those questions with practical advice, not hype. Why Compose Matters Now for UI Teams Compose has moved past the early-adopter phase. Major apps on Google Play now ship with Compose screens, and the Android team at Google has made it the default for new projects. But the real reason Compose matters isn't just industry momentum; it's the fundamental shift in how we manage state and recomposition. In the old View system, updating the UI meant finding the right View object and calling methods like setText() or setVisibility() .

Building user interfaces with Jetpack Compose feels different from the old View system. Not just because the code looks different, but because the entire mental model shifts. We've been working with Compose since its early betas, and we've seen teams struggle with the same questions: when does declarative UI actually save time, and where does it create new problems? This guide answers those questions with practical advice, not hype.

Why Compose Matters Now for UI Teams

Compose has moved past the early-adopter phase. Major apps on Google Play now ship with Compose screens, and the Android team at Google has made it the default for new projects. But the real reason Compose matters isn't just industry momentum; it's the fundamental shift in how we manage state and recomposition.

In the old View system, updating the UI meant finding the right View object and calling methods like setText() or setVisibility(). This imperative approach works, but it becomes brittle as the UI grows. You have to manually keep the View state in sync with your data model, and one missing update can cause visual bugs that are hard to trace. Compose eliminates this by letting you describe what the UI should look like for a given state, and the framework handles the rest.

That sounds ideal, but it introduces new challenges. Developers need to understand recomposition, side effects, and the cost of recomposition scopes. Teams often find that the first Compose screen is easy, but the tenth screen exposes hidden complexity. We'll address those pain points throughout this guide.

Another reason Compose matters now is the ecosystem. Libraries like Material 3, Accompanist, and third-party navigation solutions have matured. You can build a production app entirely in Compose without dropping into the View system for critical features. This wasn't true two years ago. The tooling has also improved: the Layout Inspector now supports Compose, and the recomposition counts in the debugger help you spot performance issues early.

For teams starting a new project, Compose is the obvious choice. For teams maintaining an existing app, the decision is more nuanced. We've seen successful migrations that start with a single screen, prove the pattern, then expand. The key is to avoid rewriting everything at once. Instead, identify screens that are isolated and low-risk, such as a settings screen or an onboarding flow, and convert those first. This gives the team confidence without disrupting the main user experience.

We also see a trend toward Compose being used for more than just Android. With Compose Multiplatform, you can share UI code across Android, iOS, and desktop. This is still experimental for production, but it shows where the industry is heading. Teams that invest in Compose now are positioning themselves for a future where UI logic is truly cross-platform.

In summary, Compose matters because it aligns with modern UI patterns: reactive, state-driven, and composable. It reduces boilerplate, improves testability, and makes UI code more predictable. But it also requires a shift in thinking. This guide will help you make that shift with confidence.

Core Idea: Declarative UI in Plain Language

At its heart, Compose is a declarative UI toolkit. That means you declare what the UI should look like for a given state, and the framework takes care of updating it when the state changes. This is the opposite of the imperative approach, where you manually modify UI elements in response to events.

Think of it like a recipe. In an imperative kitchen, you'd tell the chef: 'Add salt, stir, taste, add more salt, stir again.' In a declarative kitchen, you'd say: 'I want a soup that is salty enough.' The chef figures out the steps. Compose is that chef: you give it a state, and it produces the UI that matches that state.

Concretely, a Compose UI is built from composable functions. A composable function takes data and emits UI elements. For example:

@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")
}

When name changes, Compose automatically recomposes the Greeting function to show the new text. You don't need to find the TextView and call setText(). This is the core mechanism: state drives the UI, not the other way around.

The challenge is managing state correctly. Compose provides several state holders: mutableStateOf, StateFlow, LiveData, and remember. Choosing the right one depends on the scope and lifecycle of the state. For local UI state, remember { mutableStateOf(...) } is common. For shared state, you might use a ViewModel with StateFlow.

One common mistake is putting too much logic inside composable functions. Composables should be about layout and presentation, not business logic. Keep your composables 'dumb' and your ViewModels 'smart'. This separation makes testing easier and keeps your UI code clean.

Another key idea is recomposition granularity. Compose recomposes only the composables that depend on the changed state. But if you pass large objects or unstable lambdas, you might trigger unnecessary recompositions. Use remember to stabilize references, and consider using derivedStateOf for computed values. We'll see examples in the walkthrough.

Finally, understand that Compose is not a black box. You can control recomposition with keys, and you can skip recomposition with @Stable and @Immutable annotations. These tools are essential for performance, especially in lists and animations.

How Compose Works Under the Hood

To use Compose effectively, you need a mental model of its runtime. Compose has three phases: composition, layout, and drawing. Composition is where the composable functions are called and the UI tree is built. Layout measures and places each node. Drawing renders the pixels.

Recomposition happens during the composition phase. When a state changes, Compose schedules a recomposition of the affected composables. It uses a technique called 'slot tables' to track which composables depend on which state. This is efficient because it avoids re-running the entire tree.

However, recomposition is not free. If a composable function does heavy computation, it will be re-executed on every recomposition. That's why you should avoid doing heavy work inside composables. Use LaunchedEffect for side effects and remember for expensive calculations.

The layout phase is also important. Compose uses a constraint-based layout system. Each composable receives constraints from its parent and returns a size. This is similar to Flexbox on the web. The key difference from the View system is that Compose measures children only once during a layout pass (unless you use IntrinsicSize). This reduces passes and improves performance.

One common performance pitfall is using Modifier.weight() inside a Row or Column with many children. This forces the parent to measure children twice: once to determine the total weight, and again to assign sizes. For small lists, it's fine. For large lists, consider using fixed sizes or LazyColumn.

Speaking of LazyColumn, it's a workhorse for lists. It only composes and lays out the visible items, recycling the composables as the user scrolls. But it has its own recomposition rules. Each item in a LazyColumn is a separate recomposition scope. That means if you update an item's state, only that item recomposes, not the entire list. This is efficient, but you must provide stable keys to avoid unnecessary recompositions of all items.

Another internal detail: Compose uses a 'monotonic clock' to schedule recompositions. If multiple state changes happen in the same frame, they are batched into a single recomposition. This prevents flickering and improves performance. But it also means that if you read a state and then immediately change it, the UI might not update until the next frame. For most cases, this is fine, but for animations, you might need to use Animatable or animate*AsState.

Finally, understand that Compose is not thread-safe by default. State changes should happen on the main thread unless you use snapshot state in a background thread. The Compose snapshot system allows concurrent reads, but writes must be synchronized. In practice, this means you should update state from ViewModels or coroutines that use withContext(Dispatchers.Main).

Walkthrough: Building a Reusable Profile Card

Let's apply these concepts by building a reusable profile card component. This is a common UI pattern: an avatar, name, subtitle, and a list of tags. We'll design it to be flexible and performant.

First, define the data class:

data class ProfileCardData(
    val name: String,
    val subtitle: String,
    val avatarUrl: String?,
    val tags: List<String>
)

Next, the composable function:

@Composable
fun ProfileCard(
    data: ProfileCardData,
    modifier: Modifier = Modifier
) {
    Card(modifier = modifier.fillMaxWidth().padding(8.dp)) {
        Row(modifier = Modifier.padding(16.dp)) {
            AsyncImage(
                model = data.avatarUrl,
                contentDescription = "Avatar of ${data.name}",
                modifier = Modifier.size(48.dp).clip(CircleShape)
            )
            Spacer(modifier = Modifier.width(12.dp))
            Column(modifier = Modifier.weight(1f)) {
                Text(text = data.name, style = MaterialTheme.typography.titleMedium)
                Text(text = data.subtitle, style = MaterialTheme.typography.bodyMedium)
                Spacer(modifier = Modifier.height(8.dp))
                TagRow(tags = data.tags)
            }
        }
    }
}

Notice we use AsyncImage from Coil for the avatar. This is a common choice for loading images asynchronously. The TagRow is a separate composable that we'll define next.

@Composable
fun TagRow(tags: List<String>) {
    Row(modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) {
        tags.forEach { tag ->
            Surface(
                shape = RoundedCornerShape(8.dp),
                color = MaterialTheme.colorScheme.secondaryContainer,
                modifier = Modifier.padding(end = 4.dp)
            ) {
                Text(
                    text = tag,
                    modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
                    style = MaterialTheme.typography.labelSmall
                )
            }
        }
    }
}

Now, let's consider performance. The TagRow uses horizontalScroll, which is fine for a small number of tags. If tags can be many (say, 50+), consider using a LazyRow instead to avoid creating all composables at once. Also, note that the ProfileCard recomposes whenever data changes. If data is a new object every recomposition, the card will recompose even if the content is the same. To avoid this, use remember with keys or make ProfileCardData stable.

We can make the card more flexible by adding optional parameters for click actions or different layouts. For example, we could add an onClick lambda:

@Composable
fun ProfileCard(
    data: ProfileCardData,
    onClick: () -> Unit = {},
    modifier: Modifier = Modifier
) {
    Card(onClick = onClick, modifier = modifier.fillMaxWidth().padding(8.dp)) {
        // ... same content
    }
}

This is a good pattern: provide sensible defaults but allow customization. The onClick is optional, so the card works both as a static display and as a clickable item.

Finally, test the component in a list. Use LazyColumn with stable keys:

@Composable
fun ProfileList(profiles: List<ProfileCardData>) {
    LazyColumn {
        items(profiles, key = { it.name }) { profile ->
            ProfileCard(data = profile)
        }
    }
}

Using the name as a key works if names are unique. If not, use an ID. This ensures that only the changed items recompose when the list updates.

Edge Cases and Exceptions

Compose handles most common scenarios well, but there are edge cases that trip up even experienced developers. We'll cover a few here.

Accessibility and Semantics

Compose has a semantics layer that powers accessibility services like TalkBack. By default, Compose infers semantics from the UI elements, but you often need to customize it. For example, a clickable card should have a meaningful content description. Use Modifier.semantics to add or override properties.

One edge case: images. An avatar image should have a content description that describes the person, not just 'avatar'. Use the contentDescription parameter in AsyncImage or Image. If the image is decorative, set contentDescription = null to hide it from accessibility.

Another issue is custom gestures. If you implement a custom swipe gesture, you must also provide accessibility alternatives, such as a button to perform the same action. This is often overlooked.

Handling Large Lists with Stable Keys

We mentioned keys earlier. If you omit keys in a LazyColumn, Compose will use the index as the key. This can cause issues when the list changes: items might animate incorrectly or lose their state. Always provide stable, unique keys.

But what if your data doesn't have a natural ID? You can generate one using remember or a UUID. However, if the list is reordered, the generated ID should stay with the item, not the position. This is tricky. A better approach is to include an ID in your data model, even if it's just an incrementing counter.

State Hoisting and Unnecessary Recompositions

State hoisting is a pattern where state is lifted to a parent composable to make a child reusable. For example, a checkbox's checked state should be hoisted to the caller. This is good practice, but it can cause unnecessary recompositions if the parent recomposes often. To mitigate, use remember to stabilize the callback lambdas, or use derivedStateOf to compute values only when needed.

Another edge case: using mutableStateOf with complex objects. If you update a field of a data class, Compose might not detect the change because it compares references. Use copy() to create a new object, or use mutableStateListOf for lists.

Animation and Side Effects

Animations in Compose are powerful but can cause performance issues if overused. For example, animating the height of a list item using animateContentSize can cause the entire list to relayout. Use it sparingly and consider using AnimatedVisibility with expandVertically for a more efficient approach.

Side effects like network calls should be launched in a LaunchedEffect or rememberCoroutineScope. Avoid launching coroutines directly inside a composable without a scope, as they won't be cancelled on recomposition.

Limits of the Approach

Compose is not a silver bullet. It has limitations that you should know before committing to it for every screen.

Performance with Complex UIs

While Compose is fast for most UIs, it can struggle with very complex layouts, such as a screen with hundreds of nested composables or heavy custom drawing. The recomposition overhead can accumulate. Profiling with the Layout Inspector is essential. If you see high recomposition counts, consider splitting your UI into smaller composables and using remember to skip unnecessary work.

Another performance limit is the lack of hardware acceleration for some drawing operations. Compose uses the Android Canvas, which is GPU-accelerated, but custom Canvas composables with many draw calls can still be slow. For complex graphics, consider using a native View with a SurfaceView or TextureView.

Interop with the View System

If you're migrating an existing app, you'll likely need to embed Compose inside Views or vice versa. This is possible with AndroidView and ComposeView, but it adds complexity. The two systems have different lifecycle and measurement models, which can cause subtle bugs. For example, a ComposeView inside a ScrollView might not measure correctly because the ScrollView doesn't respect Compose's constraints. Workarounds exist, but they are not always clean.

We recommend limiting interop to specific screens rather than mixing them in the same layout. If you must mix, test thoroughly on different screen sizes and Android versions.

Learning Curve and Team Adoption

Compose requires a shift in thinking. Developers who are used to imperative UI often struggle with state management and recomposition. The learning curve is steep for junior developers. Teams should invest in training and code reviews to ensure consistent patterns.

Another limit is the ecosystem. While it's growing, some third-party libraries don't have Compose support yet. You may need to write custom wrappers or fall back to Views for certain components, like map views or video players.

Tooling Maturity

The Compose tooling has improved, but it's still behind the View system. The layout inspector is good, but the preview sometimes fails to render, especially with custom themes or complex state. The debugger doesn't always show the exact recomposition reason. These are minor annoyances, but they can slow down development.

Finally, Compose is still evolving. APIs change between versions, and some features (like shared element transitions) are not yet stable. If you're building a long-lived app, be prepared to update your code as Compose evolves.

Reader FAQ

Should I migrate my entire app to Compose?

Not all at once. Start with a single screen that is isolated and low-risk. Prove the pattern, then expand. Full rewrites are risky and rarely justified. Many successful apps use a hybrid approach for years.

How do I handle navigation in Compose?

Use the Navigation Compose library. It integrates well with ViewModels and supports deep links. Define your nav graph using composable functions. For complex navigation, consider using a single-activity architecture with Compose destinations.

Can I use Compose with MVVM or MVI?

Yes. Compose works well with MVVM: the ViewModel exposes state via StateFlow, and the composable collects it. For MVI, you can use a unidirectional data flow where intents are processed by a reducer and the state is observed. Compose's state management is flexible enough to support both.

How do I test Compose UI?

Use the Compose testing library. You can write tests that simulate user interactions and assert on the UI tree. For example, composeTestRule.onNodeWithText("Hello").assertIsDisplayed(). Testing state-driven UI is often easier than testing imperative UI because you can set the state and verify the output.

What about animations?

Compose has a rich animation API. Use animate*AsState for simple animations, Animatable for more control, and Transition for multi-phase animations. For shared element transitions, use the experimental SharedTransitionLayout.

How do I optimize recomposition?

Use the Layout Inspector to see recomposition counts. Avoid heavy computations in composables. Use remember to cache values. Use derivedStateOf for computed states. Provide stable keys in lists. Mark your data classes as @Immutable or @Stable to help Compose skip unnecessary recompositions.

Is Compose ready for production?

Yes, for most apps. Thousands of apps on Google Play use Compose. The core APIs are stable, and the ecosystem is mature enough for production use. However, some features (like shared element transitions and multiplatform) are still experimental. Test thoroughly on your target devices.

Share this article:

Comments (0)

No comments yet. Be the first to comment!