šŸš€ 10 Advanced Jetpack Compose Tricks I Wish I Knew Earlier as an Android Developer

Dhaval AsodariyaDhaval Asodariya
16 min read

Jetpack Compose changed the game for Android UI, but it also brought new performance quirks, hidden costs, and best practices that aren’t obvious at first. After months of debugging recompositions, battling jank, and discovering powerful Compose internals, I’ve compiled 10 advanced tricks I wish I knew earlier.
These tips will help you write cleaner, faster, and more maintainable UI code using Jetpack Compose - whether youā€˜re building complex UIs or migrating from Views. Let’s dive in.

Let’s dive in!


1ļøāƒ£Cache Expensive Work with remember to Avoid Recomposition 🧠

One of the easiest performance wins is not repeating heavy work every time your UI updates. Remember, in Compose, a composable can re-run every frame when its inputs change. If you do expensive calculations inside a composable, they can re-run on every recomposition and slow down your UI. Instead, use remember to cache results. For example, consider a list of contacts you want sorted before display:

@Composable
fun ContactList(contacts: List<Contact>, comparator: Comparator<Contact>) {
// Remember the sorted list so sorting only happens when 'contacts' or 'comparator' change
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortedWith(comparator)
    }
    LazyColumn {
        items(sortedContacts) { contact ->
            Text(text = contact.name)
        }
    }
}

In this code, remember(contacts, comparator) { ... } ensures the sorting runs only once per unique list or comparator. Without remember, scrolling the list (which triggers recomposition on new rows) would re-sort the entire list over and over.

Pro Insight:
This pattern also applies to derived state. For example, if you have a list scroll position and you only need to trigger something once it crosses a threshold, you can derive that value with derivedStateOf (covered in the next tip). In general, pushing work out of the composition body and into remembered lambdas decouples your UI from needless overhead.

2ļøāƒ£ Hoist State and Use Unidirectional Data Flow šŸ”

Compose shines when your UI components are stateless, but beginners often confuse who owns the state. State hoisting is the pattern of moving the state up to the caller, so a composable just takes its state and an event callback as parameters. This enables a single source of truth and unidirectional data flow (state flows down, events go up), which leads to cleaner, more testable code.

For example, instead of this stateful composable:

@Composable
fun HelloContent() {
    var name by rememberSaveable { mutableStateOf("") }
    TextField(
        value = name,
        onValueChange = { new -> name = new },
        label = { Text("Name") }
    )
}

We hoist the state out so that HelloContent is stateless:

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }
    HelloContent(
        name = name,
        onNameChange = { new -> name = new }
    )
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    TextField(
        value = name,
        onValueChange = onNameChange,
        label = { Text("Name") }
    )
}

Here, HelloContent simply displays name and calls onNameChange when text changes, but it doesn’t own the MutableState. Hoisting creates a clear data flow: HelloScreen owns the state, and passes it down, while HelloContent emitting events upwards. This decoupling makes the UI easier to reason about.

Now a question arises in your mind,
Does hoisting mean every composable needs two versions (stateful & stateless)?
The Answer is not necessarily, but it’s often useful. The pattern is: provide a stateful wrapper for simple use-cases, and a stateless core for full control. This way, you keep a ā€œsingle source of truthā€ for your data and avoid duplicate states. If multiple screens need the same name, they can all use the hoisted value from, say, a ViewModel or top-level composable.

Pro Insight:
Think of your UI as a pure function: inputs in, events out. Avoid hidden state in Composables. Tools like rememberSaveable (to survive process death) or ViewModel (to survive configuration changes) often back this pattern. Eventually, your state can live in a ViewModel or Repository, and the UI just observes it via Compose-friendly primitives.

3ļøāƒ£ Minimize Recomposition with derivedStateOf & Stable Keys āš™ļø

In Compose, any state read in a composable can trigger recomposition when that state changes. Sometimes, state changes very frequently, but you only want to recompose under certain conditions. This is where derivedStateOf shines: it lets you compute a value based on state, but only trigger recomposition when the derived value actually changes.

For example, imagine a ā€œScroll to Topā€ button that should appear when the first visible item index exceeds 0. You might be tracking a LazyListState. Instead of doing:

val showButton = remember { mutableStateOf(false) }
LaunchedEffect(listState.firstVisibleItemIndex) {
    showButton.value = (listState.firstVisibleItemIndex > 0)
}

It’s simpler and more efficient to do:

@Composable
fun ShowScrollToTop(listState: LazyListState) {
// derivedStateOf recalculates when index changes, but recomposes only if the Boolean actually flips
    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }
    if (showButton) {
        Button(onClick = { /* scroll to top */ }) {
            Text("Scroll to Top")
        }
    }
}

Here, derivedStateOf { ... } creates a state that re-evaluates whenever firstVisibleItemIndex changes, but Compose only recomposes ShowScrollToTop when the value of showButton actually changes (from false to true or vice versa). All intermediate index changes that do not cross zero are ignored for recomposition.

Another tip: Always use stable keys in lists or when using remember(key). For example, LazyColumn’s items(list, key = { it.id }) helps Compose identify item identity and avoid full recomposition. And if you do use remember(someKey), be sure someKey truly reflects when you want the stored value to refresh.

Pro Insight:
Whenever you have a state that’s derived from another state and changes less often, use derivedStateOf. It essentially implements a ā€œchange detectorā€ so your UI re-runs only when needed. This pattern is common in performant Compose code: couple it with good data models (e.g., immutable data classes) so state equality checks are fast and correct.

4ļøāƒ£ Prefer LazyColumn & Avoid Deep Layout Trees 🪵

A common pitfall is building complex UIs with deeply nested Column/Row combos, especially for lists. Compose provides lazy layouts (LazyColumn, LazyRow, LazyVerticalGrid, etc.) that only compose and lay out what's on-screen. Always choose a lazy layout for large or dynamic lists. For example:

LazyColumn(modifier = Modifier.fillMaxSize()) {
    items(itemsList) { item ->
        Text(text = item.title)
    }
}

This way, only visible items are composed. In contrast, using a plain Column for a list of 1000 items would compose all of them at once – a performance disaster.

Similarly, minimize unnecessary nesting. Every extra Box, Column, or Row adds overhead. Sometimes you can achieve the same effect with fewer composables or by combining modifiers. For example, prefer Modifier.padding or Modifier.layout on one element over wrapping it in extra containers.

If your list is small (just a few elements) and fixed, a simple Column approach might be fine. But even then, lazy layouts handle small lists efficiently. The big wins come when your data set grows. In practice, default to LazyColumn for scrollable content, and avoid manual scrollbars or RecyclerView bridging.

In addition to using lazy layouts, take advantage of remembered measurements. For example, if you have a custom complex header in each row, calculate its size only once via remember or a custom modifier, rather than in each recomposition. Also, if you’re showing images, make sure to reuse image resources (painterResource, rememberImagePainter with caching, etc.) - though we’re not diving into libraries here.

The key is: avoid work until absolutely needed.

Pro Insight:
Layout performance in Compose is often about avoiding overdraw and redundant passes. Use tools like the Layout Inspector (coming up) to spot deep hierarchies. Sometimes flattening multiple layers with custom Layout or Modifier.layout (see next tip) yields big gains for complex UIs.

5ļøāƒ£ Supercharge Your Workflow with Compose Previews & Tools šŸ› ļø

Jetpack Compose comes with powerful tooling. Familiarize yourself with Android Studio’s Compose-specific features:

  • @Preview: Annotate composables with @Preview to instantly see UI in the IDE without running the app. You can even create multiple previews for different states or devices:
@Preview(name = "Light Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun GreetingPreview() {
    MaterialTheme {
        Greeting("Compose")
    }
}

Previews update live as you code, saving endless emulator restarts. You can also specify device, widthDp, heightDp, or themes in the annotation. For example, @Preview(device="id:pixel_4", showBackground=true) lets you emulate a Pixel 4 device, and Focus mode in the Design tab can isolate one preview at a time.

  • Interactive Mode: Newer Android Studio versions let you interact with a preview (click buttons, switch tabs) to simulate user actions. This helps catch UI glitches early without installing the app.

  • Layout Inspector: When running your app on a device/emulator, open Layout Inspector > Compose Inspector. It visualizes your Compose hierarchy. Even better, you can show recomposition counts and highlight where recompositions occur. For instance, if a component is re-rendering too often, Layout Inspector paints it with a gradient to catch your eye. You can also view each node’s parameters and semantic information, which is invaluable for debugging.

  • Compose Profiler: In the Android Studio System Trace view, there’s a Compose section that graphs frame times, measure/layout/draw timings, and recomposition counters per frame. It’s a great way to spot jank or slow frames.

By leveraging these tools, you gain immediate feedback. Android Studio’s UI Check in Compose Preview even runs accessibility and layout audits on the fly.

Pro Insight:
Always double-check your composables in multiple configurations (dark mode, small screens, RTL) using previews. The Compose inspector is so powerful that I once caught a costly recomposition bug simply by noticing one component was painting bright gradients in the Layout Inspector. Tooling tips are often the difference between a good and a great Compose developer.

6ļøāƒ£ Embrace Clean Architecture with ViewModel + StateFlow 🧱

Under the hood, Compose UI is just a frontend to data. For robust apps, use a tried-and-true architecture like MVVM or MVI with Jetpack libraries. A common pattern: ViewModel + StateFlow (or LiveData) to hold your UI state, then collect it in Compose.

For example:

// In a ViewModel
class MainViewModel : ViewModel() {
    private val _uiState = MutableStateFlow("Hello, Jetpack Compose!")
    val uiState: StateFlow = _uiState
}

// In Composable
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
    val text by viewModel.uiState.collectAsState() // subscribe to StateFlow
    Text(text = text)
}

Here we expose a StateFlow from the ViewModel. Inside MainScreen, calling collectAsState() converts it to Compose State<String> and automatically re-renders when the value changes. Using viewModel() ensures the ViewModel survives configuration changes and is scoped properly. This keeps business logic and state updates out of the UI layer.

The official guide highlights why this pattern works: ā€œStateFlow ensures UI reactively updates on data changes, and viewModel() automatically retains the ViewModel across recompositions.ā€. And by collecting state as Compose state, you inherently follow unidirectional data flow (the ViewModel pushes new state down, and the UI emits events upwards).

Question: Should I always use StateFlow over LiveData?
Answer: StateFlow is Kotlin’s coroutine-based solution and works seamlessly with Compose. It offers more operators and doesn’t need lifecycle owners inside Composables. LiveData still works (use observeAsState()), but StateFlow is lightweight and well-aligned with coroutines. Choose what fits your team; just be consistent.

Finally, organizing your code into layers pays off. For example, one common pattern is UI State holder (a data class or sealed class) that describes all UI fields, along with ViewModel methods to update it. This way, your Composables just render state; they don’t mutate it directly. You’ll find this separation makes testing much easier.

Pro Insight:
For very complex UIs, consider an MVI approach: have a single UiState and send Intents or Actions from the UI to the ViewModel. It enforces strict state transitions and immutability. Either way, always keep your composables stateless and your business logic in ViewModels or Use-Cases.

7ļøāƒ£ Create Custom Layout Modifiers for Pixel-Perfect UIs šŸŽÆ

Compose’s Modifier system is incredibly flexible. Whenever you need custom positioning or measuring logic, you can create your own Modifier using layout { measurable, constraints -> ... }.

For example, suppose you want to position text so that the first line’s baseline is a fixed distance from the top (not just the top of the box). The standard padding(top = X.dp) measures from the box edge, not from the text’s baseline. Using a custom modifier, you can achieve ā€œpadding from baselineā€.

Here’s a simplified version:

fun Modifier.firstBaselineToTop(firstBaselineToTop: Dp) = layout { measurable, constraints ->
// Measure the composable (a Text)
    val placeable = measurable.measure(constraints)
// Get the first baseline (throw if no baseline found)
    val firstBaseline = placeable[FirstBaseline]
        .takeIf { it != AlignmentLine.Unspecified }
        ?: error("No baseline found!")
// Calculate the new height and position
    val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
    val height = placeable.height + placeableY
    layout(placeable.width, height) {
// Place the text at (0, placeableY)
        placeable.placeRelative(0, placeableY)
    }
}

This modifier measures the text, reads its first baseline position, and then adjusts the layout height and Y offset so that the baseline ends up firstBaselineToTop from the top. Using it is simple:

Text("Hi there!", Modifier.firstBaselineToTop(24.dp))

Custom layout example: aligning a Text by its first baseline. The top part of the above image uses firstBaselineToTop(24.dp) to fix the distance from the top to the first line’s baseline, while the bottom uses normal top padding. This level of control is only possible by measuring the text’s baseline explicitly.

Without such a custom modifier, achieving consistent baseline spacing would require manual tweaking or nested layouts.

Now the question arises,
Why not just add top padding?
Answer: Normal padding adds space above the composable’s bounding box, not relative to the text baseline. For text, designers often want baseline alignment. The layout modifier allows full control: you measure the child (via measurable.measure), compute sizes, and call placeable.place(x, y). Anything not placed is invisible, so you have to carefully set layout(width, height) with your intended size.

Custom modifiers can do much more: you can intercept draw, handle touch, or enforce aspect ratios. The key is to think of them as mini-layout blocks. And remember to make your modifiers extension functions on Modifier so they chain nicely.

Pro Insight:
If you find yourself writing the same measurement code in multiple places, factor it out as a modifier (or a reusable Layout). For example, you could create a Modifier.customCircularLayout(...) that arranges children in a circle, or a Modifier.gradientBackground() that draws a gradient. These abstractions make your UI code declarative and concise.

8ļøāƒ£ Master Animations with updateTransition & Coroutines šŸŽžļø

Compose’s animation APIs are very powerful. Beyond simple animate*AsState, Transition and updateTransition let you synchronize multiple animated values based on a target state.

Here’s how you can animate a box’s color and size together when a boolean state changes:

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatingBox(boxState: BoxState) {
// Create a Transition backed by the current boxState
    val transition = updateTransition(targetState = boxState, label = "BoxTransition")

// Animate color between Gray and Red based on state
    val color by transition.animateColor(label = "color") { state ->
        if (state == BoxState.Collapsed) Color.Gray else Color.Red
    }
// Animate size between 64.dp and 128.dp
    val size by transition.animateDp(label = "size") { state ->
        if (state == BoxState.Collapsed) 64.dp else 128.dp
    }

    Box(
        Modifier
            .size(size)
            .background(color)
    )
}

In this snippet, updateTransition watches boxState. When boxState changes, the transition runs both animateColor and animateDp in parallel, interpolating their values over time. This ensures the color and size animations start and end together. You can even use easing functions, keyframes, or spring specs inside animate* blocks.

Another tip:
For continuous or repeating animations, use rememberInfiniteTransition.
For example, to pulse a color back and forth:

val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
    initialValue = Color.Green,
    targetValue = Color.Blue,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    )
)
Box(Modifier.fillMaxSize().background(color))

This will smoothly animate between green and blue forever.

Pro Insight:
For very complex animations (like full-screen transitions or choreographed sequences), extract your animation logic into a separate composable or even a custom AnimationSpec class. You can create data classes that hold multiple State values from a Transition and return them together. This decouples the ā€œanimation dataā€ from the UI, making testing and reuse easier.

9ļøāƒ£ Write Robust UI Tests with Compose’s Testing API āœ…

Testing Compose UIs is surprisingly straightforward using the official testing APIs. Start with createComposeRule() (or createAndroidComposeRule if you need an Activity).

Here’s a basic example of a UI test:

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testGreeting() {
    composeTestRule.setContent {
        MyAppTheme {
            MainScreen() // Your composable under test
        }
    }
// Find a button or text and perform actions/assertions
    composeTestRule.onNodeWithText("Continue").performClick()
    composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
}

This uses the semantics tree under the hood: onNodeWithText("...") finds composables with that text content. The test automatically waits for the UI to become idle before proceeding, so you don’t have to manage synchronization. You can also query by content descriptions, tags (Modifier.testTag("myTag")), or use .onNodeWithTag("myTag") for non-text items.

Do I always need to use createAndroidComposeRule<MainActivity>()?
Only if your composable depends on an Activity or you need to test intents/permissions. For most pure UI tests, createComposeRule() is sufficient. It’s lightweight and doesn’t require launching an entire Activity.

Remember to tag important elements with testTag if there’s no visible text. For example, if you have an Icon button, you might do:

IconButton(
    onClick = { /*...*/ },
    modifier = Modifier.testTag("SettingsButton")
) { /* icon content */ }

Then in tests: onNodeWithTag("SettingsButton").performClick(). This makes your tests robust against text changes.

Pro Insight:
Automating your Compose UI tests early can pay huge dividends. Use composeTestRule.setContent to inject fake ViewModels or state for isolated testing. Because Compose tests use semantics, they naturally support both Compose and older View UIs together. And don’t forget to run tests on multiple screen sizes or configurations for true confidence (there are Compose test utilities for different densities and font scales as well).

šŸ”Ÿ Interop with Views + Profile Performance Like a Pro šŸ‘€

Even in a fully Compose app, you’ll often need interoperability with the Android View system. There are two directions:

  • Compose in XML Views: You can add a <androidx.compose.ui.platform.ComposeView> in an XML layout or a Fragment/Activity. In code, find that view and call setContent { ... } on it. Be sure to set a ViewCompositionStrategy. For example:
// In a Fragment using XML layout with a ComposeView
val composeView = view.findViewById(R.id.compose_view)
composeView.apply {
// Dispose the Compose tree when the view's LifecycleOwner is destroyed
    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    setContent {
        MaterialTheme {
            Text("Hello Compose in XML!")
        }
    }
}

This snippet inflates an XML with a ComposeView, then uses setContent { } to host Compose UI within it. The ViewCompositionStrategy ensures Compose cleans up resources appropriately. Similarly, you can use AndroidView inside Compose to embed traditional Views. For example, if you want to show a legacy MapView or a custom view inside your Compose UI, do:

AndroidView(
    modifier = Modifier.fillMaxSize(),
    factory = { context -> MyCustomLegacyView(context) },
    update = { view ->
// Optionally update the view with new parameters
        view.setData(viewModel.data)
    }
)

In newer Compose versions (1.4.0+), there’s even an overload of AndroidView for lazy lists that reuse the same View instance when items get recycled. It introduces onReset and onRelease callbacks so you can clear state when the view is reused (similar to ViewHolder patterns).

Gradual migration and reusing mature components. Many apps have decades of Android View code or libraries. Interop allows adopting Compose piecemeal. Eventually, you might move all the way to Compose, but until then, these bridges let you combine the best of both worlds.

Finally, profile performance. Android Studio’s tools (mentioned earlier) are key. Use the Layout Inspector’s recomposition counters (Figure below shows enabling ā€œShow Recomposition Countsā€) to spot unexpected updates. In the Android Profiler / System Trace, look at the Compose section: it can tell you how long compose, measure, and draw take per frame.

Android Studio Layout Inspector view: enable ā€œShow Recomposition Countsā€ to see how often each composable is re-running. Here the AnimatedContent composable is recomposing 48 times, which may indicate a performance issue to investigate.

If you detect jank or excessive recompositions, revisit your code: use remember, lift state, or simplify your layout. Also consider baseline profiles or startup tracing (outside Compose scope) to speed up app launch.

Pro Insight:
Performance tuning is an ongoing process. Regularly profile your app on real devices. Remember that Compose is still evolving; newer compiler optimizations may change what costs the most. Stay updated on Jetpack Compose releases and experiment with things like Modifier.Node (for custom drawing) or snapshot API tweaks in critical sections if you hit limits.


Jetpack Compose is more than just a new way to build UI - it’s a mindset shift. These tricks helped me write cleaner, faster, and more maintainable code, and I hope they do the same for you.

šŸ’¬ Which tip did you find most useful, or what Compose trick do you swear by? Drop it in the comments! Let’s build a solid community of Compose developers helping each other grow.

If this article saved you some debugging hours or sparked an ā€œAha!ā€ moment, consider giving it a like ā¤ļø and sharing it with your team or Twitter/X followers. And if you want more advanced Android and Kotlin insights, follow me here on Hashnode - I publish practical dev content weekly.

1
Subscribe to my newsletter

Read articles from Dhaval Asodariya directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dhaval Asodariya
Dhaval Asodariya

šŸ‘Øā€šŸ’» Software Engineer | šŸ’” Kotlin Expert | šŸ“± Android Enthusiast Previously SDE-III at DhiWise, I’m a Kotlin-focused developer passionate about building scalable, modern software-primarily on Android, but also exploring AI šŸ¤– and backend technologies. I use this space to share practical insights, clean code practices, and thoughts on the future of tech šŸš€