Rules
Stability¶
A couple of rules (ComposeUnstableReceiver and ComposeUnstableCollections) exist to flag parameters and receivers whose instability would prevent a composable from being skipped during recomposition.
These are significantly less important in the era of Compose strong skipping, which makes composables skippable regardless of parameter/receiver stability. Because of that, these checks are disabled by default.
To opt back in, enable each issue via your lint configuration:
lint {
enable += "ComposeUnstableReceiver"
enable += "ComposeUnstableCollections"
}
State¶
Hoist all the things¶
Compose is built upon the idea of a unidirectional data flow, which can be summarised as: data/state flows down, and events fire up. To implement that, Compose advocates for the pattern of hoisting state upwards, enabling the majority of your composable functions to be stateless. This has many benefits, including far easier testing.
In practice, there are a few common things to look out for:
- Do not pass ViewModels (or objects from DI) down.
- Do not pass
State<Foo>orMutableState<Bar>instances down.
Instead, pass down the relevant data to the function, and optional lambdas for callbacks.
More information: State and Jetpack Compose
Related rule: ComposeViewModelForwarding
State should be remembered in composables¶
Be careful when using mutableStateOf (or any of the other state builders) to make sure that you remember the instance. If you don’t remember the state instance, a new state instance will be created when the function is recomposed.
Note: This used to be enforced by the
ComposeRememberMissingrule, which was removed in favor of Compose’s ownUnrememberedMutableStatelint. The first party check ships with the Compose runtime, is enabled by default, and covers all@StateFactoryMarkerbuilders (includingmutableIntStateOf,mutableStateListOf, etc.) without the false positives our rule had (e.g.retain { ... }and non-@Composablelambdas).
Avoid using unstable collections¶
Collections are defined as interfaces (e.g. List<T>, Map<T>, Set<T>) in Kotlin, which can’t guarantee that they are actually immutable. For example, you could write:
val list: List<String> = mutableListOf<String>()
The variable is constant, its declared type is not mutable but its implementation is still mutable. The Compose compiler cannot be sure of the immutability of this class as it just sees the declared type and as such declares it as unstable.
To force the compiler to see a collection as truly ‘immutable’ you have a couple of options.
You can use Kotlinx Immutable Collections:
val list: ImmutableList<String> = persistentListOf<String>()
Alternatively, you can wrap your collection in an annotated stable class to mark it as immutable for the Compose compiler.
@Immutable
data class StringList(val items: List<String>)
// ...
val list: StringList = StringList(yourList)
Note: It is preferred to use Kotlinx Immutable Collections for this. As you can see, the wrapped case only includes the immutability promise with the annotation, but the underlying List is still mutable. More info: Jetpack Compose Stability Explained, Kotlinx Immutable Collections
Disabled by default
This is a stability check and is disabled by default. Enable it with your lint configuration.
Related rule: ComposeUnstableCollections
Composables¶
Do not use inherently mutable types as parameters¶
This practice follows on from the ‘Hoist all the things’ item above, where we said that state flows down. It might be tempting to pass mutable state down to a function to mutate the value.
This is an anti-pattern though as it breaks the pattern of state flowing down, and events firing up. The mutation of the value is an event which should be modelled within the function API (a lambda callback).
There are a few reasons for this, but the main one is that it is very easy to use a mutable object which does not trigger recomposition. Without triggering recomposition, your composables will not automatically update to reflect the updated value.
Passing ArrayList<T>, MutableState<T>, ViewModel are common examples of this (but not limited to those types).
Related rule: ComposeMutableParameters
Unstable receivers¶
In compose, all parameters must be stable or immutable in order for a composable function to be restartable or skippable. This includes the containing class or receiver, which the compose-compiler will treat as the 0th argument. Using an unstable receiver is usually a bug, so this lint offers a warning to raise this issue.
Disabled by default
This is a stability check and is disabled by default — it is largely obsoleted by Strong Skipping. Enable it with your lint configuration.
More info: Compose API Stability
Related rule: ComposeUnstableReceiver
Remove unnecessary @Composable annotations¶
A function or property annotated with @Composable that never calls another @Composable function or reads a @Composable property (like a CompositionLocal’s current) doesn’t actually need the annotation. This often happens when a composable used to call into the composition but that usage was later removed.
// The @Composable here does nothing — it can be removed
@Composable
fun greeting() = println("hello")
Removing the redundant annotation drops the unneeded composer plumbing and makes intent clearer, and this check ships a quickfix that removes it for you.
If every composable function or property used by a declaration is marked @ReadOnlyComposable, the declaration can use that annotation too. ComposeReadOnlyComposable reports these declarations and provides a quickfix.
@Composable
@ReadOnlyComposable
fun currentGreeting(): String = LocalGreeting.current
// This can also be @ReadOnlyComposable.
@Composable
fun greetingLength(): Int = currentGreeting().length
Library authors
A trivially-bodied public composable can be intentionally @Composable as part of your API contract; removing it would be a source/binary-incompatible change. Suppress or disable this rule for such declarations.
Configuration
The ignore-annotated option provides an escape hatch for this check, which can be useful when a codebase generates other contracts based on a composable declaration. Specify a comma-separated list of fully qualified annotation names in lint.xml:
<issue id="ComposeRedundantComposable">
<option
name="ignore-annotated"
value="example.MyAnnotation,example.MyOtherAnnotation" />
</issue>
Related rules: ComposeRedundantComposable and ComposeReadOnlyComposable
Avoid unnecessary restart groups¶
Note
@NonRestartableComposable removes the wrapper’s restart and skip boundary.
A small composable that only forwards its parameters to one other composable may not benefit from its own restart and skip boundary. Marking the wrapper as non-restartable avoids generating its parameter checks and restart code.
@Composable
@NonRestartableComposable
fun OutlinedAction(
onClick: () -> Unit,
content: @Composable () -> Unit,
) = Action(onClick = onClick, content = content)
ComposeNonRestartableComposable reports these pass-through functions and provides a quickfix that adds @NonRestartableComposable. The check only reports concrete, Unit-returning functions whose entire body is one composable call with forwarded parameters or literal values. It can also report a chain of pass-through functions when the immediate callee is already non-restartable.
If a default parameter reads state, Compose may need to recompose a parent instead. The rule ignores functions that calculate arguments, read state, create lambdas, or do other work in the body. It does not inspect default parameter expressions, so check them before applying the quickfix.
Related rule: ComposeNonRestartableComposable
Do not emit content and return a result¶
Composable functions should either emit layout content, or return a value, but not both.
If a composable should offer additional control surfaces to its caller, those control surfaces or callbacks should be provided as parameters to the composable function by the caller.
More info: Compose API guidelines
Related rule: ComposeContentEmitterReturningValues
Configuration
To add your custom composables so they are used in this rule (things like your design system composables), you can configure a content-emitters option in lint.xml.
<issue id="ComposeMultipleContentEmitters">
<option name="content-emitters" value="CustomEmitter,AnotherEmitter" />
</issue>
Do not emit multiple pieces of content¶
A composable function should emit either 0 or 1 pieces of layout, but no more. A composable function should be cohesive, and not rely on what function it is called from.
You can see an example of what not to do below. InnerContent() emits a number of layout nodes and assumes that it will be called from a Column:
Column {
InnerContent()
}
@Composable
private fun InnerContent() {
Text(...)
Image(...)
Button(...)
}
However InnerContent could just as easily be called from a Row which would break all assumptions. Instead, InnerContent should be cohesive and emit a single layout node itself:
@Composable
private fun InnerContent() {
Column {
Text(...)
Image(...)
Button(...)
}
}
There is a slight exception to this rule, which is when the function is tied to an appropriate scope with an extension receiver or context parameter, like so:
@Composable
private fun ColumnScope.InnerContent() {
Text(...)
Image(...)
Button(...)
}
@Composable
context(scope: ColumnScope)
private fun InnerContent() {
WeightedText("Title", scope)
WeightedText("Subtitle", scope)
}
@Composable
private fun WeightedText(text: String, scope: ColumnScope) {
with(scope) {
Text(text, Modifier.weight(1f))
}
}
Related rule: ComposeMultipleContentEmitters
Configuration
To add your custom composables so they are used in this rule (things like your design system composables), you can configure a content-emitters option in lint.xml.
<issue id="ComposeMultipleContentEmitters">
<option name="content-emitters" value="CustomEmitter,AnotherEmitter" />
</issue>
Do not invoke slots in more than once place¶
Slot parameters provide a convenient and idiomatic way to accept arbitrary content for a component.
Invoke a slot at most once on each execution path. Calling it twice in sequence, from a loop, both inside and after a branch, or through multiple child composables can compose the same content more than once.
Calls in mutually exclusive branches are allowed by this rule because only one branch runs. This applies to content(), content.invoke(), nullable content?.invoke(), and passing the slot to another Unit-returning composable.
Callers of a component that takes a slot parameter have natural expectations about the lifecycle of that slot. Even if there are visual or structure changes inside the component, callers expect the internal state of the slot to be preserved. Switching between call sites can still reset that state, even when each execution path calls the slot only once.
Components should either use custom layouts to meet this expectation or movableContentOf.
More information: Lifecycle expectations for slot parameters
Related rule: SlotReused
Naming multipreview annotations properly¶
Multipreview annotations should be named by using Previews as suffix (or Preview if just one). These annotations have to be explicitly named to make sure that they are clearly identifiable as a @Preview alternative on its usages.
More information: Multipreview annotations
Related rule: ComposePreviewNaming
Naming @Composable functions properly¶
Composable functions that return Unit should start with an uppercase letter. They are considered declarative entities that can be either present or absent in a composition and therefore follow the naming rules for classes.
However, Composable functions that return a value should start with a lowercase letter instead. They should follow the standard Kotlin Coding Conventions for the naming of functions for any function annotated @Composable that returns a value other than Unit
More information: Naming Unit @Composable functions as entities and Naming @Composable functions that return values
Related rules: ComposeNamingUppercase,ComposeNamingLowercase
Configuration
To allow certain regex patterns of names, you can configure the allowed-composable-function-names option in lint.xml.
<issue id="ComposeNamingUppercase,ComposeNamingLowercase">
<option name="allowed-composable-function-names" value=".*Presenter" />
</issue>
Ordering @Composable parameters properly¶
When writing Kotlin, it’s a good practice to write the parameters for your methods by putting the mandatory parameters first, followed by the optional ones (aka the ones with default values). By doing so, we minimize the number times we will need to write the name for arguments explicitly.
Modifiers occupy the first optional parameter slot to set a consistent expectation for developers that they can always provide a modifier as the final positional parameter to an element call for any given element’s common case.
More information: Kotlin default arguments, Modifier docs and Elements accept and respect a Modifier parameter.
Related rule: ComposeParameterOrder
Make dependencies explicit¶
ViewModels¶
When designing composables, try to be explicit about the dependencies they take in. If you acquire a ViewModel or an instance from DI in the body of the composable, you are making this dependency implicit, which has the downsides of making it hard to test and harder to reuse.
To solve this problem, you should inject these dependencies as default values in the composable function.
Let’s see it with an example.
@Composable
private fun MyComposable() {
val viewModel = viewModel<MyViewModel>()
// ...
}
But, if you change it to pass these instances via the composable function parameters, you could provide the instance you want directly in your tests without any extra effort. It would also have the upside of the function being explicit about its external dependencies in its signature.
@Composable
private fun MyComposable(
viewModel: MyViewModel = viewModel(),
) {
// ...
}
Related rule: ComposeViewModelInjection
CompositionLocals¶
CompositionLocal makes a composable’s behavior harder to reason about. As they create implicit dependencies, callers of composables that use them need to make sure that a value for every CompositionLocal is satisfied.
Although uncommon, there are legit use cases for them, so this rule provides an allowlist so that you can add your CompositionLocal names to it so that they are not flagged by the rule.
Related rule: ComposeCompositionLocalUsage
Configuration
To add your custom CompositionLocal to your allowlist, you can configure a allowed-composition-locals option in lint.xml.
<issue id="ComposeCompositionLocalUsage">
<option name="allowed-composition-locals" value="LocalEnabled,LocalThing" />
</issue>
Preview composables should not be public¶
When a composable function exists solely because it’s a @Preview, it doesn’t need to have public visibility because it won’t be used in actual UI. To prevent folks from using it unknowingly, we should restrict its visibility to private.
Related rule: ComposePreviewPublic
Note: If you are using Detekt, this may conflict with Detekt’s UnusedPrivateMember rule. Be sure to set Detekt’s ignoreAnnotated configuration to [‘Preview’] for compatibility with this rule.
Modifiers¶
When should I expose modifier parameters?¶
Modifiers are the beating heart of Compose UI. They encapsulate the idea of composition over inheritance, by allowing developers to attach logic and behavior to layouts.
They are especially important for your public components, as they allow callers to customize the component to their wishes.
More info: Always provide a Modifier parameter
Related rule: ComposeModifierMissing
Configuration
By default, this rule will only check for modifiers in public methods. However, you can configure the threshold via using visibility-threshold option in lint.xml.
<issue id="ComposeModifierMissing">
<option name="visibility-threshold" value="only_public" />
</issue>
Possible values are:
only_public: (default) Will check for missing modifiers only for public composables.public_and_internal: Will check for missing modifiers in both public and internal composables.all: Will check for missing modifiers in all composables.
Don’t re-use modifiers¶
Modifiers which are passed in are designed so that they should be used by a single layout node in the composable function. If the provided modifier is used by multiple composables at different levels, unwanted behaviour can happen.
In the following example we’ve exposed a public modifier parameter, and then passed it to the root Column, but we’ve also passed it to each of the descendant calls, with some extra modifiers on top:
@Composable
private fun InnerContent(modifier: Modifier = Modifier) {
Column(modifier) {
Text(modifier.clickable(), ...)
Image(modifier.size(), ...)
Button(modifier, ...)
}
}
@Composable
private fun InnerContent(modifier: Modifier = Modifier) {
Column(modifier) {
Text(Modifier.clickable(), ...)
Image(Modifier.size(), ...)
Button(Modifier, ...)
}
}
For a top-level if or when, pass the incoming modifier to every branch whose composable accepts one. If one branch omits it, that branch ignores the caller’s modifier.
@Composable
fun Content(state: State, modifier: Modifier = Modifier) {
when (state) {
State.Loading -> LoadingContent()
is State.Loaded -> LoadedContent(state.data, modifier)
}
}
Pass the modifier in both branches:
@Composable
fun Content(state: State, modifier: Modifier = Modifier) {
when (state) {
State.Loading -> LoadingContent(modifier)
is State.Loaded -> LoadedContent(state.data, modifier)
}
}
Branches that call composables without a modifier parameter are allowed. The rule also checks modifier arguments passed to composable function parameters. It does not require the function parameter itself to be called in every branch.
Related rule: ComposeModifierReused
Modifiers should have default parameters¶
Composables that accept a Modifier as a parameter to be applied to the whole component represented by the composable function should name the parameter modifier and assign the parameter a default value of Modifier. It should appear as the first optional parameter in the parameter list; after all required parameters (except for trailing lambda parameters) but before any other parameters with default values. Any default modifiers desired by a composable function should come after the modifier parameter’s value in the composable function’s implementation, keeping Modifier as the default parameter value.
More info: Modifier documentation
Related rule: ComposeModifierWithoutDefault
Migrate to Modifier.Node¶
Modifier.composed { ... } is no longer recommended due to performance issues.
You should use the Modifier.Node API instead, as it was designed from the ground up to be far more performant than composed modifiers.
Related rule: ComposeModifierComposed
Use Material 3¶
Rule: ComposeM2Api
Material 3 (M3) reached stable in October 2022. In apps that have migrated to M3, there may be androidx.compose.material (M2) APIs still remaining on the classpath from libraries or dependencies that can cause confusing imports due to the many similar or colliding Composable names in the two libraries. The ComposeM2Api rule can prevent these from being used.
Lint Configuration
This rule is disabled default and is opt-in.
You can enable it via the lint DSL in Gradle:
android {
lint {
enable += "ComposeM2Api"
error += "ComposeM2Api"
}
}
lint.xml:
<lint>
<issue id="ComposeM2Api" severity="error"/>
</lint>
Allow-list Configuration
To allow certain APIs (i.e. for incremental migration), you can configure a allowed-m2-apis option in lint.xml.
<issue id="ComposeM2Api" severity="error">
<option name="allowed-m2-apis" value="Text,Surface" />
</issue>
Name mangling
Kotlin will mangle the names of internal functions, which may match when resolving functions due to overloads. compose-lints will attempt to unmangle these names to match any in an allow-list, but can be disabled in case of any issues by setting the enable-mangling-workaround option in lint.xml to false.
<issue id="ComposeM2Api" severity="error">
<option name="enable-mangling-workaround" value="false" />
</issue>
Related docs links
- Announcement post: https://material.io/blog/material-3-compose-stable
- Docs: https://m3.material.io/develop/android/jetpack-compose
- Migration guide: https://developer.android.com/jetpack/compose/themes/material2-material3
- Guidance: https://developer.android.com/jetpack/compose/themes/material3
- Reply (primary sample app): https://github.com/android/compose-samples/tree/main/Reply
- More samples: https://github.com/android/compose-samples
Lazy lists¶
Don’t use hashCode as a key¶
Lazy* layouts (LazyColumn, LazyRow, LazyVerticalGrid, …) and Pager composables let you provide a key for each item. Item keys must be unique — if two items resolve to the same key, the layout throws at runtime.
hashCode() is not a good key, because hashCodes are not guaranteed to be unique. For example, all of these Strings share the same hashCode:
"aaaa", "aabB", "aac#", "bBaa", "bBbB", "c#aa", "c#bB", "c#c#"
It might work fine until some day a server sends data with a colliding hashCode and your app starts crashing. So this rule flags any hashCode() call used in a key:
// Don't do this
LazyColumn {
items(people, key = { it.hashCode() }) { /* ... */ }
}
// Do this instead: use a genuinely unique, stable identifier
LazyColumn {
items(people, key = { it.id }) { /* ... */ }
}
If you truly need an identity-based key and aren’t targeting Kotlin Multiplatform, java.lang.System.identityHashCode is allowed.
Related rule: ComposeItemKeyHashCode