Rules
State¶
Hoist all the things¶
Compose is built upon the idea of unidirectional data flow: data/state flows down, and events fire up. To achieve this, Compose advocates for hoisting state upwards, making most composable functions stateless. This has many benefits, including easier testing.
flowchart TB
subgraph Parent[Parent Composable]
S[State]
end
subgraph Child[Child Composable]
U[UI]
end
S -->|state flows down| U
U -->|events fire up| S
In practice, watch out for these common issues:
- Do not pass ViewModels (or objects from DI) down.
- Do not pass
MutableState<T>instances down. - Do not pass inherently mutable types that cannot be observed.
Instead, pass the relevant data to the function and use lambdas for callbacks.
More information: State and Jetpack Compose
compose:vm-forwarding-check ktlint ViewModelForwarding detekt
State should be remembered in composables¶
Be careful when using mutableStateOf (or any of the other State<T> 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.
compose:remember-missing-check ktlint RememberMissing detekt
Use mutableStateOf type-specific variants when possible¶
Compose provides type-specific state variants that avoid autoboxing on JVM platforms, making them more memory efficient. Use these instead of mutableStateOf when working with primitives or primitive collections.
Primitives:
| Instead of | Use |
|---|---|
mutableStateOf<Int> |
mutableIntStateOf |
mutableStateOf<Long> |
mutableLongStateOf |
mutableStateOf<Float> |
mutableFloatStateOf |
mutableStateOf<Double> |
mutableDoubleStateOf |
Primitive Lists:
| Instead of | Use |
|---|---|
mutableStateOf(List<Int>) |
mutableIntListOf |
mutableStateOf(List<Long>) |
mutableLongListOf |
mutableStateOf(List<Float>) |
mutableFloatListOf |
Primitive Sets:
| Instead of | Use |
|---|---|
mutableStateOf(Set<Int>) |
mutableIntSetOf |
mutableStateOf(Set<Long>) |
mutableLongSetOf |
mutableStateOf(Set<Float>) |
mutableFloatSetOf |
Primitive Maps:
| Instead of | Use |
|---|---|
mutableStateOf(Map<Int, Int>) |
mutableIntIntMapOf |
mutableStateOf(Map<Int, Long>) |
mutableIntLongMapOf |
mutableStateOf(Map<Int, Float>) |
mutableIntFloatMapOf |
mutableStateOf(Map<Long, Int>) |
mutableLongIntMapOf |
mutableStateOf(Map<Long, Long>) |
mutableLongLongMapOf |
mutableStateOf(Map<Long, Float>) |
mutableLongFloatMapOf |
mutableStateOf(Map<Float, Int>) |
mutableFloatIntMapOf |
mutableStateOf(Map<Float, Long>) |
mutableFloatLongMapOf |
mutableStateOf(Map<Float, Float>) |
mutableFloatFloatMapOf |
The collection variants also apply to PersistentList, ImmutableList, PersistentSet, ImmutableSet, PersistentMap, and ImmutableMap.
compose:mutable-state-autoboxing ktlint MutableStateAutoboxing detekt
Composables¶
Do not use inherently mutable types as parameters¶
This follows from the "Hoist all the things" rule above. While it might be tempting to pass mutable state down to a function, this is an anti-pattern that breaks unidirectional data flow. Mutations are events that should be modeled as lambda callbacks in the function API.
The main issue is that mutable objects often don't trigger recomposition. Without recomposition, your composables won't automatically update to reflect the new value.
Common examples include passing ArrayList<T> or ViewModel, but this applies to any mutable type.
compose:mutable-params-check ktlint MutableParams detekt
Do not use MutableState as a parameter¶
Using MutableState<T> as a parameter promotes shared ownership of state between a component and its caller.
Instead, make the component stateless and let the caller handle state changes. If the component needs to mutate the parent's property, consider creating a ComponentState class with a domain-specific field backed by mutableStateOf(...).
When a component accepts MutableState as a parameter, it can change it at will. This splits state ownership, and the caller loses control over how and when the state changes.
More info: Compose API guidelines
compose:mutable-state-param-check ktlint MutableStateParam detekt
Be mindful of effect keys¶
In Compose, effects like LaunchedEffect, produceState, and DisposableEffect use keys to control when they restart:
EffectName(key1, key2, key3, ...) { block }
Using the wrong keys can cause:
- Bugs if the effect restarts less often than needed.
- Inefficiency if the effect restarts more often than necessary.
To ensure proper behavior:
- Include variables used in the effect block as key parameters.
- Use
rememberUpdatedStateto prevent unnecessary restarts when you don't want to restart the effect (e.g., inside a flow collector). - Variables that never change (via
rememberwith no keys) don't need to be passed as effect keys.
Here are some examples:
// ❌ onClick changes, but the effect won't be pointing to the right one!
@Composable
fun MyComposable(onClick: () -> Unit) {
LaunchedEffect(Unit) {
delay(10.seconds) // something that takes time, a flow collection, etc
onClick()
}
// ...
}
// ✅ onClick changes and the LaunchedEffect won't be rebuilt -- but will point at the correct onClick!
@Composable
fun MyComposable(onClick: () -> Unit) {
val latestOnClick by rememberUpdatedState(onClick)
LaunchedEffect(Unit) {
delay(10.seconds) // something that takes time, a flow collection, etc
latestOnClick()
}
// ...
}
// ✅ _If we don't care about rebuilding the effect_, we can also use the parameter as key
@Composable
fun MyComposable(onClick: () -> Unit) {
// This effect will be rebuilt every time onClick changes, so it will always point to the latest one.
LaunchedEffect(onClick) {
delay(10.seconds) // something that takes time, a flow collection, etc
onClick()
}
}
More info: Restarting effects and rememberUpdatedState
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 needs to offer additional control surfaces to its caller, those should be provided as parameters.
More info: Compose API guidelines
Note: To add your custom composables so they are used in this rule (things like your design system composables), you can add
composeEmittersto this rule config in Detekt, orcompose_emittersto your .editorconfig in ktlint.
Do not emit multiple pieces of content¶
A composable function should emit zero or one layout nodes. Each composable should be cohesive and not depend on its call site.
In this example, InnerContent() emits multiple layout nodes and assumes it will be called from a Column:
// This will render:
// <text>
// <image>
// <button>
Column {
InnerContent()
}
// ❌ Unclear UI, as we emit multiple pieces of content at the same time
@Composable
private fun InnerContent() {
Text(...)
Image(...)
Button(...)
}
However, InnerContent could just as easily be called from a Row or Box, which would break these assumptions:
// ❌ This will render: <text><image><button>
Row {
InnerContent()
}
// ❌ This will render all elements on top of each other.
Box {
InnerContent()
}
Instead, InnerContent should be cohesive and emit a single layout node itself:
// ✅
@Composable
private fun InnerContent() {
Column {
Text(...)
Image(...)
Button(...)
}
}
There is one exception: when the function is defined as an extension function of an appropriate scope:
// ✅
@Composable
private fun ColumnScope.InnerContent() {
Text(...)
Image(...)
Button(...)
}
compose:multiple-emitters-check ktlint MultipleEmitters detekt
Note: To add your custom composables so they are used in this rule (things like your design system composables), you can add
composeEmittersto this rule config in Detekt, orcompose_emittersto your .editorconfig in ktlint.
Slots for main content should be the trailing lambda¶
Content slots (typically content: @Composable () -> Unit or nullable variants) should always be the last parameter so they can be written as a trailing lambda. This makes the UI flow more natural and easier to read.
// ❌
@Composable
fun Avatar(content: @Composable () -> Unit, subtitle: String, modifier: Modifier = Modifier) { ... }
// ✅ The usage of the main content as a trailing lambda is more natural
@Composable
fun Avatar(subtitle: String, modifier: Modifier = Modifier, content: @Composable () -> Unit) { ... }
@Composable
fun Profile(user: User, modifier: Modifier = Modifier) {
Column(modifier) {
Avatar(subtitle = user.name) {
AsyncImage(url = user.avatarUrl)
}
}
}
compose:content-trailing-lambda ktlint ContentTrailingLambda detekt
Content slots should not be reused in branching code¶
Content slots should not be disposed and recomposed when the parent composable changes due to branching code.
Ensure that the lifecycle of slot composables matches the lifecycle of the parent composable or is tied to visibility within the viewport.
To ensure proper behavior:
- Use
remember { movableContentOf { ... } }to make sure the content is preserved correctly; or - Create a custom layout where the internal state of the slot is preserved.
// ❌
@Composable
fun Avatar(user: User, content: @Composable () -> Unit) {
if (user.isFollower) {
content()
} else {
content()
}
}
// ✅
@Composable
fun Avatar(user: User, content: @Composable () -> Unit) {
val content = remember { movableContentOf { content() } }
if (user.isFollower) {
content()
} else {
content()
}
}
More information: Lifecycle expectations for slot parameters
compose:content-slot-reused ktlint ContentSlotReused detekt
Avoid trailing lambdas for event handlers¶
In Compose, trailing lambdas are typically used for content slots. To avoid confusion, event lambdas (e.g., onClick, onValueChange) should not be placed in the trailing position.
Recommendations:
- Required event lambdas: Place them before the
Modifierparameter to clearly distinguish them from content slots. - Optional event lambdas: Avoid placing them as the last parameter when possible.
// ❌ Using an event lambda (like onClick) as the trailing lambda when in a composable makes it error prone and awkward to read
@Composable
fun MyButton(modifier: Modifier = Modifier, onClick: () -> Unit) { /* ... */ }
@Composable
fun SomeUI(modifier: Modifier = Modifier) {
MyButton {
// This is an onClick, but by reading it people would assume it's a content slot
}
}
// ✅ By moving the event lambda to be before Modifier, we avoid confusion
@Composable
fun MyBetterButton(onClick: () -> Unit, modifier: Modifier = Modifier) { /* ... */ }
@Composable
fun SomeUI(modifier: Modifier = Modifier) {
MyBetterButton(
onClick = {
// Now this param is straightforward to understand
},
)
}
Naming CompositionLocals properly¶
CompositionLocals should be named with Local as a prefix, followed by a descriptive noun (e.g., LocalTheme, LocalUser). This makes implicit dependencies obvious and easy to identify.
More information: Naming CompositionLocals
compose:compositionlocal-naming ktlint CompositionLocalNaming detekt
Naming multipreview annotations properly¶
Multipreview annotations should use Previews as a prefix (e.g., @PreviewsLightDark). This ensures they are clearly identifiable as @Preview alternatives at their usage sites.
More information: Multipreview annotations and Google's own predefined annotations
compose:preview-annotation-naming ktlint PreviewAnnotationNaming detekt
Naming @Composable functions properly¶
Composable functions that return Unit should start with an uppercase letter. They represent declarative UI entities and follow class naming conventions.
Composable functions that return a value should start with a lowercase letter, following standard Kotlin Coding Conventions.
More information: Naming Unit @Composable functions as entities and Naming @Composable functions that return values
compose:naming-check ktlint ComposableNaming detekt
Naming Composable annotations properly¶
Custom Composable annotations (tagged with @ComposableTargetMarker) should have the Composable suffix (for example, @GoogleMapComposable or @MosaicComposable).
Ordering @Composable parameters properly¶
In Kotlin, required parameters should come first, followed by optional ones (those with default values). This minimizes the need to explicitly name arguments.
The modifier parameter should be the first optional parameter, creating a consistent expectation that callers can always provide a modifier as the final positional parameter.
If there is a content lambda, it should be used as a trailing lambda.
- Required parameters (no default values)
- Optional parameters (have default values)
modifier: Modifier = Modifier- The rest of optional params
- [Optionally] A trailing lambda. If there is a
contentslot, it should be it.
flowchart LR
A[Required parameters] --> B[Modifier, if any]
B --> C[Optional parameters]
C --> D[Optionally a trailing lambda, eg a content slot]
An example of the above could be this:
// ✅
@Composable
fun Avatar(
imageUrl: String, // Required parameters go first
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier, // Optional parameters, start with modifier
enabled: Boolean = true, // Other optional parameters
loadingContent: @Composable (() -> Unit)? = null,
errorContent: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit, // A trailing lambda _can_ be last. Recommended for `content` slots.
) { ... }
More information: Kotlin default arguments, Modifier docs and Elements accept and respect a Modifier parameter.
compose:param-order-check ktlint ComposableParamOrder detekt
Naming parameters properly¶
Event parameters in composable functions should follow the pattern on + verb in present tense, like onClick or onTextChange. For consistency, use present tense verbs.
// ❌
@Composable
fun Avatar(onShown: () -> Unit, onChanged: () -> Unit) { /* ... */ }
// ✅
@Composable
fun Avatar(onShow: () -> Unit, onChange: () -> Unit) { /* ... */ }
compose:parameter-naming ktlint ParameterNaming detekt
Movable content should be remembered¶
movableContentOf and movableContentWithReceiverOf must be used inside a remember function.
These need to persist across compositions; if detached from the composition, they are immediately recycled.
compose:remember-content-missing-check ktlint RememberContentMissing detekt
Make dependencies explicit¶
ViewModels¶
Composables should be explicit about their dependencies. Acquiring a ViewModel or DI instance inside the composable body makes the dependency implicit, which makes testing and reuse harder.
Instead, inject dependencies as default parameter values.
// ❌ The VM dependency is implicit here.
@Composable
private fun MyComposable() {
val viewModel = viewModel<MyViewModel>()
// ...
}
viewModel internals.
By passing dependencies as parameters with default values, you can easily provide test instances and make the function's dependencies clear in its signature.
// ✅ The VM dependency is explicit
@Composable
private fun MyComposable(
viewModel: MyViewModel = viewModel(),
) {
// ...
}
compose:vm-injection-check ktlint ViewModelInjection detekt
CompositionLocals¶
CompositionLocal creates implicit dependencies that make composable behavior harder to understand. Callers must ensure every CompositionLocal is satisfied, which isn't apparent from the API.
While there are legitimate use cases, this rule provides an allowlist for CompositionLocal names that shouldn't trigger warnings.
compose:compositionlocal-allowlist ktlint CompositionLocalAllowlist detekt
Note: To add your custom
CompositionLocalto your allowlist, you can addallowedCompositionLocalsto this rule config in Detekt, orcompose_allowed_composition_localsto your .editorconfig in ktlint.
Preview composables should not be public¶
Composable functions that exist solely for @Preview don't need public visibility since they won't be used in production UI. Make them private to prevent accidental usage.
compose:preview-public-check ktlint PreviewPublic detekt
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 central to Compose UI. They enable composition over inheritance by letting developers attach logic and behavior to layouts.
They are especially important for public components, allowing callers to customize behavior and appearance.
More info: Always provide a Modifier parameter
compose:modifier-missing-check ktlint ModifierMissing detekt
Modifier order matters¶
The order of modifier functions is important. Each function transforms the Modifier returned by the previous one, so the sequence affects the final result:
// ❌ The UI will be off, as the pressed state ripple will extend beyond the intended shape
@Composable
fun MyCard(modifier: Modifier = Modifier) {
Column(
modifier
// Tapping on it does a ripple, the ripple is bound incorrectly to the composable
.clickable { /* TODO */ }
// Create rounded corners
.clip(shape = RoundedCornerShape(8.dp))
// Background with rounded corners
.background(color = backgroundColor, shape = RoundedCornerShape(8.dp))
) {
// rest of the implementation
}
}
We can address this by simply reordering the modifiers.
// ✅ The UI will be now correct, as the pressed state ripple will have the same shape as the element
@Composable
fun MyCard(modifier: Modifier = Modifier) {
Column(
modifier
// Create rounded corners
.clip(shape = RoundedCornerShape(8.dp))
// Background with rounded corners
.background(color = backgroundColor, shape = RoundedCornerShape(8.dp))
// Tapping on it does a ripple, the ripple is bound correctly now to the composable
.clickable { /* TODO */ }
) {
// rest of the implementation
}
}
More info: Modifier documentation
compose:modifier-clickable-order ktlint ModifierClickableOrder detekt
Modifiers should be used at the top-most layout of the component¶
Apply the modifier parameter to the root layout as the first modifier in the chain. Since modifiers control external behavior and appearance, they must be applied at the top level. You can chain additional modifiers after the parameter if needed.
More info: Compose Component API Guidelines
compose:modifier-not-used-at-root ktlint ModifierNotUsedAtRoot detekt
Don't re-use modifiers¶
A modifier parameter should only be used by a single layout node. Reusing it across multiple composables at different levels causes unexpected behavior.
In this example, the modifier is passed to the root Column and also to each child:
// ❌ When changing `modifier` at the call site, it will affect the whole layout in unintended ways
@Composable
private fun InnerContent(modifier: Modifier = Modifier) {
Column(modifier) {
Text(modifier.clickable(), ...)
Image(modifier.size(), ...)
Button(modifier, ...)
}
}
Column and use fresh Modifier instances for descendants:
// ✅ When changing `modifier` at the call site, it will only affect the external container of the UI
@Composable
private fun InnerContent(modifier: Modifier = Modifier) {
Column(modifier) {
Text(Modifier.clickable(), ...)
Image(Modifier.size(), ...)
Button(Modifier, ...)
}
}
compose:modifier-reused-check ktlint ModifierReused detekt
Modifiers should have default parameters¶
The modifier parameter should default to Modifier and appear as the first optional parameter (after required parameters but before other optional ones). Any default modifiers the composable needs should be chained after the parameter in the implementation, not in the default value.
More info: Modifier documentation
compose:modifier-without-default-check ktlint ModifierWithoutDefault detekt
Naming modifiers properly¶
The main modifier parameter should be named modifier.
Modifiers for specific subcomponents should be named xModifier (e.g., headerModifier for a header subcomponent) and follow the same default value guidelines.
More info: Modifier documentation
compose:modifier-naming ktlint ModifierNaming detekt
Avoid Modifier extension factory functions¶
The composed {} API for creating custom modifiers is no longer recommended due to performance issues. Use Modifier.Node instead.
More info: Modifier.Node, Compose Modifier.Node and where to find it, by Merab Tato Kutalia, Compose modifiers deep dive, with Leland Richardson and Composed modifier docs.
compose:modifier-composed-check ktlint ModifierComposed detekt
ComponentDefaults¶
ComponentDefaults object should match the composable visibility¶
If your composable has an associated Defaults object, it should have the same visibility as the composable. This lets consumers build upon the original defaults instead of copy-pasting them.
More info: Compose Component API Guidelines
compose:defaults-visibility ktlint DefaultsVisibility detekt
Opt-in rules¶
These rules are disabled by default
You'll need to explicitly enable them individually in your project's detekt/ktlint configuration.
Don't use Material 2¶
Material Design 3 supersedes Material 2, offering updated theming, components, and Material You personalization features like dynamic color. Use Material 3 for new projects.
More info: Migration to Material 3
compose:material-two ktlint Material2 detekt
Avoid using unstable collections¶
Did you know?
You can add the kotlin collections to your stability configuration (kotlin.collections.*) to make this rule unnecessary.
Kotlin collection interfaces (List<T>, Map<T>, Set<T>) can't guarantee immutability. For example:
// ❌ The compiler won't be able to infer that the list is immutable
val list: List<String> = mutableListOf()
The variable is constant and the declared type is immutable, but the implementation is mutable. The Compose compiler only sees the declared type and marks it as unstable.
To make the compiler treat a collection as immutable, you have two options:
You can use Kotlinx Immutable Collections:
// ✅ The compiler knows that this list is immutable
val list: ImmutableList<String> = persistentListOf<String>()
Alternatively, wrap your collection in a stable class:
// ✅ The compiler knows that this class is immutable
@Immutable
data class StringList(val items: List<String>)
// ...
val list: StringList = StringList(yourList)
Note: Kotlinx Immutable Collections is preferred. The wrapper approach only promises immutability via annotation while the underlying
Listremains mutable.
More info: Jetpack Compose Stability Explained, Kotlinx Immutable Collections
compose:unstable-collections ktlint UnstableCollections detekt
Naming previews properly¶
Configure the naming strategy for previews to match your project's conventions.
By default, this rule requires Preview as a suffix. You can change this via the previewNamingStrategy property:
suffix: Previews should havePreviewas suffix.prefix: Previews should havePreviewas prefix.anywhere: Previews should containPreviewin their names.
compose:preview-naming ktlint PreviewNaming detekt