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

Table of contents
- 1ļøā£Cache Expensive Work with remember to Avoid Recomposition š§
- 2ļøā£ Hoist State and Use Unidirectional Data Flow š
- 3ļøā£ Minimize Recomposition with derivedStateOf & Stable Keys āļø
- 4ļøā£ Prefer LazyColumn & Avoid Deep Layout Trees šŖµ
- 5ļøā£ Supercharge Your Workflow with Compose Previews & Tools š ļø
- 6ļøā£ Embrace Clean Architecture with ViewModel + StateFlow š§±
- 7ļøā£ Create Custom Layout Modifiers for Pixel-Perfect UIs šÆ
- 8ļøā£ Master Animations with updateTransition & Coroutines šļø
- 9ļøā£ Write Robust UI Tests with Composeās Testing API ā
- š Interop with Views + Profile Performance Like a Pro š

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 callsetContent { ... }
on it. Be sure to set aViewCompositionStrategy
. 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.
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 š