The Do’s and Don’ts of Jetpack Compose

Jetpack Compose header

Jetpack Compose is fun. It allows us to build beautiful, functional UIs very quickly and in a reactive way which has unlocked new architectural patterns that were previously very tricky to integrate with the old View based system.

As with anything new, it is also very easy for us to shoot ourselves in the foot using it because a lot of the internals and intricacies of how it works are a mystery to most of us.

I know I shot myself in the foot MANY times in the last year while working on my personal side project UhuruPhotos

While I am FAR from a Jetpack Compose expert (when people ask me how it works, I still reply with ‘magic 🤷‍♂ī¸’), I have compiled a list of gotchas I encountered. I hope you might find something useful here and avoid repeating my mistakes.

So without further ado, and not in order of significance, here are my do’s and don’ts of Jetpack Compose:

🔗 1. Immutable / Stable parameters

Composable methods will be skipped by the compose runtime when the parameters of the method do not change.

However in order for the runtime to know what parameters changed, it needs to know they are immutable.

All primitive types (Int, Bool, etc.) are treated by the runtime as immutable by default.

//DO this

@Composable
fun Widget(
    title: String, // <-- ✅ good. Immutable string
    count: Int, // <-- ✅ good. Immutable int
) {
    //...
}

Collection types cannot be guaranteed to be immutable so the runtime will treat them as mutable and recompose.

//DON'T do this

@Composable
fun Widget(
    title: String,
    items: List<String>, // <-- ❌ bad. List cannot be guaranteed to not have immutable data
) {
    //...
}

⚠ī¸ To be clear, the above will not cause bugs in the technical sense. The code will still work and your composable will render just fine. The problem is that the `Widget` composable will recompose every time the calling composable scope composes itself. It will never be skipped.

Depending on where this method is called from, this could be a real issue. If you add this to a scrollable container for example that has part of its content recompose on scroll, you will be recomposing the `Widget` up to 60 times every second (this is a worst-case scenario of course)

There are a couple of ways to fix this.

Classes in the kotlinx.collections.immutable package are considered, as the name suggests, immutable by the compose runtime and therefore their data can be treated as not changed.

//DO this

@Composable
fun Widget(
    title: String,
    items: ImmutableList<String>, // <-- ✅ good. ImmutableList is guaranteed to not have mutable data
) {
    //...
}

Non-data classes cannot be guaranteed to be immutable so they should be avoided.

//DON'T this

class Items(...)

@Composable
fun Widget(
    title: String,
    items: Items, // <-- ❌ bad. Items is not guaranteed to be immutable
) {
    //...
}

You can avoid using ImmutableList and the like by wrapping your collection in a non-data class as long as you mark that class @Immutable. This instructs the compose runtime to treat the data inside the class as not modified as long as the same instance of the class is used.

//DO this

@Immutable
class Items(
    val items: List<String>, // <-- ✅ good. notice the use of the potentially mutable List here
)

@Composable
fun Widget(
    title: String,
    items: Items, // <-- ✅ good. Items is immutable even though it contains a List
) {
    //...
}

Data classes containing only vals of immutable types can be treated by the runtime as immutable by default.

//DO this

// no need for @Immutable
data class FootNote(
    val footnote: String, // <-- ✅ good. primitive type so immutable by default
)
// no need for @Immutable
data class Items(
    val title: String, // <-- ✅ good. primitive type so immutable by default
    val items: ImmutableList<String>, // <-- ✅ good. `ImmutableList` is immutable
    val footnote: FootNote, // <-- ✅ good. FootNote is an immutable data class
)

@Composable
fun Widget(
    items: Items, // <-- ✅ good. Items is an immutable data class
) {
    //...
}

data classes are not magic. If they contain other potentially mutable types or they have var declarations, compose will treat the data class as potentially mutable and this may cause recompositions.

//DON'T do this

// note the absence of @Immutable here
data class Items(
    var title: String, // <-- ❌ bad. primitive type is var so it is mutable
    val items: List<String>, // <-- ❌ bad. List is not guaranteed to be immutable by default even though it is val
)

@Composable
fun Widget(
    items: Items, // <-- ❌ bad. Items cannot be guaranteed to be immutable
) {
    //...
}

⚠ī¸ Using immutable parameters should always prefered over mutable ones IMHO but if, for some reason, you need access to a mutable parameter, you should create a wrapper class and mark is as stable

You can create wrapper classes for stable parameters by annotating them with @Stable

// DO this if you must

@Stable // <-- ✅ good. even though this contains a mutable parameter, the runtime will skip recomposition until it is notified
class Title(
    var title by mutableStateOf<String>(""),
)

@Composable
fun Widget(
    title: Title, // <-- ✅ good. Title is stable so will be treated as skippable until it notifies for recomposition
) {
    //...
}

You can read more about stability in Jetpack Compose in the official docs

🔗 2. Remember to remember

Composition scopes can be invoked multiple times per second so it’s very important to limit the amount of object allocation to a minimum.

Jetpack Compose offers us a way to do this: remember

Avoid object allocation inside composable functions

// DON'T do this

@Composable
fun Widget() {
  val title = Title(), // <-- ❌ bad. Object allocations may be called multiple times and should be avoided
  //..
}

Remembering object allocations will only invoke them once during initial composition and they will then skipped during recompositions

// DO this

@Composable
fun Widget() {
  val title = remember { // <-- ✅ good. The object Title will only be created once
    Title()
  }
  // ...
}

There are occasions when you want the object allocation to be re-invoked. This is needed usually when the object being created is given some state as a parameter and you need the object to be recreated when the incoming state changes.

Jetpack compose provides a way to invalidate remembered invocations using a key

// DO this

@Composable
fun Widget(
  val titleText: String,
) {
  val title = remember(titleText) { // <-- ✅ good. This will re-invoke the Title constructor when the `titleText` changes
    Title(titleText)
  }
  // ...
}

Jetpack Compose provides us with a very useful mechanism for reducing the amount of recompositions, derivedStateOf. This is an often misunderstood concept (I know I misunderstood it) but it is simpler than it may first appear.

When you have some state (call it state OUTPUT) that changes based on another state (call it INPUT) and state INPUT changes more frequently than the outcome of state OUTPUT, then you want to use derivedStateOf.

A good example of this is a slider. Say you have the current value of a slider, and you want to change the color of a text field to green or red based on the state of slider. You want it to be red if the slider is below 50% and green if it is above.

In this example, state INPUT is the current slider value and state OUTPUT is the color of the text field. State INPUT changes more frequently than state OUTPUT.

You might think that one way to implement this would be something like this:

// DON'T do this

@Composable
fun Widget() {
  val sliderValue = // code that gives a value from 0-100
  val textColor = remember(sliderValue) { // <-- ❌ bad. this is evaluated every time the sliderValue changes
    if (sliderValue) <= 50 Color.RED else Color.GREEN
  }
  Text("Slider", color = textColor)
  Slider(value = sliderValue)
  // ...
}

There is no need to recompose every time the slider value changes, we only need to do this when the color changes. This is the way to achieve this:

// DO this

@Composable
fun Widget() {
  val sliderValue = // code that gives a value from 0-100
  val textColor = remember {  // Notice the lack of a key here
    derivedStateOf { // <-- ✅ good. this is evaluated every time the sliderValue changes but will not trigger recomposition
      if (sliderValue) <= 50 Color.RED else Color.GREEN
    }
  }
  Text("Slider", color = textColor)
  Slider(value = sliderValue)
  // ...
}

🔗 3. State

I find it easier to make my composables ‘dumb’ in the sense they are simply there to render the state I give them instead of holding their own state internally. This allows for better separation of concerns and makes for a cleaner architecture IMHO that is easier to test as well (I refer you to the Humble Dialog by Martin Fowler for further reading if you are interested).

⚠ī¸Please note that this is a personal preference and not gospel. We are now entering architectural discussion territory and points like this one are debatable

// DON'T do this

@Composable
fun Widget(
  onChanged: (Boolean) -> Unit,
) {
  var isChecked by remember { // <-- ❌ bad. The state of the checkbox is encapsulated in the composable and duplicated externally
    mutableStateOf(false)
  }
  CheckBox(
    checked = isChecked,
    onCheckedChange = {
      checked = it
      onChanged(it)
    },
  )
}

The code above encapsulates the checked state of the checkbox and exposes a callback to notify callers when the state changes. The problem in my opinion is that the composable is now stateful and is not easy to reason about (and test). It also makes architectures like MVI much harder to implement as you now have to effectively duplicate the state of said widget in two places and try to keep them in sync.

If you hoist the state of each composable out of it and pass it as a parameter, this makes the composable stateless which is easier to reason about and makes your architecture of choice the sole source of truth about the state of your UI.

// DO this

@Composable
fun Widget(
  isChecked: Boolean, // <-- ✅ good. The state of the checkbox is always passed in. There is only one source of truth
  onChanged: (Boolean) -> Unit,
) {
  CheckBox(
    checked = isChecked,
    onCheckedChange = onChanged, // note that it is now the responsibility of the caller to update the state and recompose to reflect it
  )
}

The composable is now ‘dumb’. It only knows how to render the state it is given and notify the caller of state change requests. It is now up to the caller to make sure that the state is modified properly and that the new value is passed down to the composable to re-render the new state.

⚠ī¸This is an uncomfortable idea for some as it breaks from our existing notion of what a ‘view’ is, but that is simply what many years of using the Android View system has taught us. Making our UI elements stateless makes for a much cleaner architecture in my opinion.

The ideal of a completely stateless UI is great and all in theory but, in practice, there are some complicated UI elements that just need their internal state because trying to hoist it up to our own domain layer and trying to update the elements ourselves can lead to interaction bugs. Some examples:

  • A map view which keeps the current camera view, even when it’s being dragged
  • A scrollable container keeping track of its scroll state
  • An input text field that keeps track not only of the text displayed but as you type dynamically changes its contents

In these scenarios it is useful to separate, in our architecture, what I call a UI ‘interaction state‘ and what is our domain state. In the example of a map view, perhaps we don’t need to know the current internally held viewport every moment the user changes it. Perhaps we only need the ‘resting’ state of the viewport to perform further business logic, or indeed we might not even need the viewport state at all in our domain layer.

In the case of the input text field, we don’t need to try and hoist the internal state to our model, which might refresh slower than the user is typing, causing caret jump bugs. We likely only need the text that is input either periodically or when the user stops typing.

In these scenarios, the example from 3.1 is valid

// DO this

@Composable
fun Widget(
  onMapMoved: (Bounds) -> Unit,
) {
  val mapState = rememberMapState() // perhaps a third party library provides this
  Map(
    state = mapState,
    onViewPortChanged = onMapMoved, // This gets called as frequently as the map library needs, might be less frequent than needed by the ui to maintain a smooth animation but is good enough for our domain
  )
}

⚠ī¸Before we dive into this one, this falls under micro-optimisations IMHO and you shouldn’t worry too much about designing your composables this way unless they are causing you problems. In that sense, it’s a useful tool in your arsenal that you deploy when needed

This is taken straight out of the official docs as I can not do a better job at describing it.

TL;DR: If your hoisted state passes through a few layers of composables, and only the bottom layers need access to certain parts of the state, reading the state in those composables via a lamba will only cause the composables themselves to recompose and not their parents

// (maybe) DON'T do this

@Composable
fun Widget() {
  val someState = // comes from somewhere
  val anotherState = // comes from somewhere
  SubWidget(someState = someState) // <-- ❌ bad. When someState changes, the SubWidget and its parent scope, Widget will recompose too
  AnotherUnrelatedSubWidget(anotherState) // <-- ❌ bad. Even worse, any other composable inside Widget will also recompose (or skipped if the parameters to it have not changed)
}
// (maybe) DO this

@Composable
fun Widget() {
  val someState = // comes from somewhere
  val anotherState = // comes from somewhere
  SubWidget(someStateReader = { someState }) // <-- ✅ good. When someState changes, only the SubWidget will recompose and not the Widget parent scope
  AnotherUnrelatedSubWidget(anotherState) // <-- ✅ good. Doesn't recompose when someState changes as the Widget didn't recompose
}

If you ignored my advice from point 3.1 (DON’T encapsulate state), or if you are using a complex UI element as described in point 3.3, remember that you should NEVER update the encapsulated state from inside the composable scope itself

// DON'T do this. Ever. Seriously

@Composable
fun Widget() {
  var isChecked by remember {
    mutableStateOf(false)
  )
  CheckBox(checked = isChecked)
  // some other composable code ...
  isChecked = !isChecked // <-- ❌ bad! This assignment is done inside a composable scope. It will be executed EVERY time the Widget composable recomposes which, depending on its internals, could be done multiple times in a row, even up to many times a second.
}

The above code will keep flipping the state of the checkbox from checked to unchecked and back every time the composable recomposes, which could happen multiple times in a row.

State should ALWAYS be modified inside lambas that live outside the composable scope

// DO this if you really must hold encapsulated state.

@Composable
fun Widget() {
  var isChecked by remember {
    mutableStateOf(false)
  )
  CheckBox(
    checked = isChecked,
    onCheckedChange = {
      isChecked = it, // <-- ✅ good-ish. The state is modified inside a lambda and not from the composable scope.
    },
  )
  // some other composable code ...
}

Anything that is shared horizontally across your UI should not be passed down your composable tree via parameters. This includes things like theme related state (colors, shapes etc), things like analytics related code, and so on.

Doing so tightly couples large amounts of your UI code to things that you might want to change independent of the individual UI components you have created. It also doesn’t logically belong to the signature definition of a UI element from a domain logic point of view.

// DON'T do this

@Composable
fun Widget(
  isDarkTheme: Boolean, // <-- ❌ bad. this should be set in the app theme and not passed to each composable
  darkButtonColors: ButtonColors, // <-- ❌ bad. this is theme related stuff that shouldn't form part of the composable contract
  lightButtonColors: ButtonColors, // <-- ❌ bad. this is theme related stuff that shouldn't form part of the composable contract
  analytics: (String) -> Unit, // <-- ❌ bad. analytics is a cross-cutting concern and shouldn't form part of the composable contract
) {
  Button(
    onClick = {
      analytics("action pressed")
      // other things
    }
    colors = if (isDarkTheme) darkButtonColors else lightButtonColors, // <-- ❌ bad. this is theme related stuff that shouldn't form part of the composable contract
  ) {
    Text(text = "Action")
  }
  // ...
}

Theme related values should be accessed via the locally accessible MaterialTheme, which internally uses three CompositionLocal instances. Other cross-cutting concerns should be provided via CompositionLocal providers (or provide your own wrapper objects for them as a convenience, similar to MaterialTheme).

// DO do this

// at the root of your UI in the activity/fragment

val analytics: Analytics // this comes from your injection code or elsewhere
setContent {
  AppTheme {
    CompositionLocalProvider(
      LocalAnalytics provides analytics
    ) {
      // ...
      Widget()
    }
  }
}

val LocalAnalytics =
    compositionLocalOf<Analytics> { throw IllegalStateException("not initialized") }
    
@Composable
fun AppTheme(
  content: @Composable () -> Unit,
) {
  val colors = if (isSystemInDarkTheme()) {
    DarkColorPalette // you can create this as you please
  } else {
    LightColorPalette  // you can create this as you please
  }
  MaterialTheme(
     colors = colors,
  ) {
    content()
  }
}

@Composable
fun Widget() { // notice no parameters relating to colors or analytics
  val analytics = LocalAnalytics.current // <-- ✅ good. cross-cutting code is accessed via composition local
  Button(
    onClick = {
      analytics.send("action pressed")
      // other things
    },
    // ✅ good. no code relating to colors. These are set at the theme level and automatically applied. 
    // If needed they can still be accessed via MaterialTheme.colors inside the composable
  ) {
    Text(text = "Action")
  }
  // ...
}

Colors are applied ‘globally’ to all composables under the specified AppTheme (note that you can create multiple custom themes and have them nested within each other and each will affect the composables inside them only) so there is no need to pass color information down to individual composables via their public API.

Similarly, for cross cutting concerns such as analytics, you can access these via a composition local directly at the call site where needed instead of having to pollute your composables API with unrelated code.

⚠ī¸ We might debate the wisdom of using analytics directly inside your composables (in lambdas of course), and whether this is really a ‘good thing to do’. You could argue that this should belong somewhere else in your code but I will leave that debate for another time.

Also note that CompositionLocals can be abused as they are effectively a service locator pattern and it is very tempting to use them to sidestep the composables method signatures and provide many things ‘globally’. It is up to you to determine where the line between abuse and helpfulness lies in your codebase.

LaunchedEffects are a very useful tool for running a piece of code from inside your composable during initial composition and/or on specific state changes

// DO this

@Composable
fun Widget() {
  Text("Hello")
  
  LaunchedEffect(Unit) { // <-- ✅ good. The code inside this lambda will run once during the initial composition of Widget and never again
    suspendLog("widget was composed") // we are inside a coroutine scope so we can run suspending code
  }
}

You might want to rerun the code inside the launched effect based on the value of a state (or more)

// DO this

@Composable
fun Widget(
  text: String,
) {
  Text(text)
  
  LaunchedEffect(text) { // <-- ✅ good. we are using the value of text as a key here which will re run the code if text changes. You can use multiple keys for composite states
    suspendLog("widget was composed with $text") // we are inside a coroutine scope so we can run suspending code
  }
}

Similar to LaunchedEffect, a DisposableEffect is executed during the initial composition but it lets you run specific code when the composable is disposed from the composition tree

// DO this

@Composable
fun Widget(
  text: String,
) {
  Text(text)
  val scope = rememberCoroutineScope()
  
  DisposableEffect(text) { // <-- ✅ good. like above this executes during initial composition
    scope.launch { // DisposableEffect does not provide a coroutine scope by default
      suspendLog("widget was composed with $text")
    }
    
    onDispose {
      scope.launch { // DisposableEffect does not provide a coroutine scope by default
        suspendLog("widget $text was removed from composition") // we are inside a coroutine scope so we can run suspending code
      }
    }
  }
}

🔗 4. Help yourself and others

There are various tools and techniques to make coding in Jetpack Compose fun and easy for yourself and your coworkers.

There is nothing more jarring than opening a piece of code and, on top of having to understand what it does, not seeing things where you expect them to be.

There are various conventions that have been established by the Jetpack Compose team that we should all follow to make our composables easier to read for a new comer

// DO this

@Composable
fun Widget( // ✅ good. Always have your composables in PascalCase
  modifier: Modifier = Modifier, // <-- ✅ good. allow your composable to be externally modified. Provide a default value. Modifier goes first!
  //...
  onAction: () -> Unit = {}, // <-- ✅ good. callback lambdas should go at the end of the parameter list and should provide sane defaults
  content: @Composable () -> Unit, // <-- ✅ good. If your composable contains a slot for external content provide it as the last parameter to allow trailing lambda syntax. Don't provide a default unless your composable should mostly be used without custom content.
) {
  // ...
}

ℹī¸ You can read more in the official guidelines:

API Guidelines for Jetpack Compose
API Guidelines for @Composable components in Jetpack Compose

The Layout Inspector is an invaluable tool bundled with Android Studio. You can access it from the tools bar inside AS or from the Tools menu bar.

It not only provides a way to check your layout including its properties (along with a cool 3D visualization of all your composables!) but, more importantly for this post, it allows you to see the number of compositions for each of your composables on screen, live as you use your app

Read more about it in the links provided and use it daily

recomposeHighlighter is an amazing utility function that the Google team have created, that is for some reason still not part of the official toolkit, that can MASSIVELY reduce the time it takes for you to spot misbehaving composables.

It is basically a modifier extension you put in any of your composables and it will show you, in real time on your phone, how many times that composable has composed within a 3 second window. It does this by placing a border around any composable that composes. The more it does the thicker the border gets and the redder it gets.

I cannot stress enough how much this little utility has helped me when troubleshooting laggy UI in my app so I highly recommend it.

You can find more about it in this post by the Google Play team (or direct link to github here)

That is all for now. I hope you found my list of DOs and DON’Ts helpful. Let me know if I missed any or if I made any mistakes in some of them in the comments below and please share this post with your network 🙏

Leave a Reply

Your email address will not be published. Required fields are marked *