| name | cmp-practices |
| description | Compose Multiplatform best practices and anti-patterns guide. Use when writing, reviewing, or debugging Compose Multiplatform / KMP UI code — covers state, recomposition, side effects, MVI, platform parity, and performance. |
| trigger | /cmp-practices |
/cmp-practices — Compose Multiplatform Best Practices
Version baseline (as of May 2025): CMP 1.11.0 (stable) / 1.12.0-alpha01 (cutting edge) · Ktor 3.5.0 latest (project on 3.3.2) · coroutines 1.10.2 · Kotlin 2.3.21 · Navigation3 1.0.0-alpha06
When invoked, apply this knowledge actively while writing or reviewing code. Flag every anti-pattern encountered. Do not skip sections that are relevant to the current file.
Reference Index
Consult these files for deeper guidance. They are not auto-loaded — read them when the topic is directly relevant.
| Topic | File | When to use |
|---|
| MVI state modeling, domain layer, where logic belongs | references/architecture.md | Architecture questions, new screen design |
| Cross-cutting anti-patterns with BAD/GOOD examples | references/anti-patterns.md | Code review, spotting violations |
| Navigation3 — scenes, adaptive layouts, tabs, deep links | references/navigation-3.md | Adding routes, dialogs, adaptive layouts |
| Coroutines & Flow — operators, dispatchers, stateIn | references/coroutines-flow.md | Flow chains, scope, StateFlow setup |
| Recomposition deep dive — three phases, API decision table | references/performance.md | Performance issues, stability problems |
| Ktor HttpClient setup, DTOs, repository pattern | references/networking-ktor.md | New API endpoints, networking setup |
| JWT Bearer auth, WebSocket, SSE | references/networking-ktor-auth.md | Auth setup, real-time features |
| KMP placement guide, interface vs expect/actual | references/cross-platform.md | Any commonMain API decision |
| Compose resources — Res vs R, fonts, MVI integration | references/resources.md | Strings, drawables, plurals, fonts |
| Three phases, modifier ordering, slot pattern, CompositionLocal | references/compose-essentials.md | Core Compose fundamentals |
| SKIE, Flow→Swift, UIKitView, Swift interop | references/ios-swift-interop.md | iOS interop, SKIE setup |
| MVI discipline, naming, anti-overengineering | references/clean-code.md | Code review, ViewModel design |
| RemoteMediator, offline-first paging | references/paging-offline.md | Paged lists with Room + Ktor |
| Metro DI — ViewModel, Repository, platform modules | references/metro-di.md | Any new screen, ViewModel, or feature module |
1. State and Recomposition
DO
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) { ... }
val painter = remember { Painter(...) }
var expanded by rememberSaveable { mutableStateOf(false) }
val isButtonEnabled by remember { derivedStateOf { email.isNotEmpty() && password.length >= 8 } }
LazyColumn {
items(list, key = { it.id }) { item -> ItemRow(item) }
}
AVOID
@Composable fun SearchBar() {
var query by remember { mutableStateOf("") }
}
Text(text = viewModel.uiState.value.name)
val filtered = list.filter { it.active }
var items = mutableListOf<Item>()
2. Stability and Skippability
Compose skips recomposing a composable only when all parameters are stable. Instability causes unnecessary recompositions.
Stability rules
| Type | Stable? | Fix |
|---|
Int, String, Boolean, primitives | ✅ | — |
data class with only stable fields | ✅ (Kotlin 2+) | — |
List<T>, Map<K,V> | ❌ (mutable contract) | Use ImmutableList from kotlinx.collections.immutable or @Immutable |
| Lambdas referencing unstable captures | ❌ | Wrap in remember { } or use stable ViewModel reference |
Classes with var fields | ❌ | Use @Stable annotation only when you guarantee observable mutations |
@Immutable
data class CustomerUiState(val name: String, val email: String)
val onClick = remember(id) { { onItemClick(id) } }
val onClick = { onItemClick(counter) }
Verify with compiler reports
kotlinOptions { freeCompilerArgs += listOf("-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=build/compose_reports") }
./gradlew assembleDebug
3. Side Effects
Use the correct effect API — wrong choice causes leaks, double-execution, or missed cancellation.
| Need | API |
|---|
| Launch coroutine tied to composable lifecycle | LaunchedEffect(key) |
| Subscribe/unsubscribe to external source | DisposableEffect(key) |
| Push value to non-Compose code after recomposition | SideEffect { } |
| Produce state from suspend fun | produceState |
| Share reusable coroutine scope across composables | rememberCoroutineScope() |
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> ... }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
viewModel.load()
LaunchedEffect(Unit) { viewModel.loadUser(userId) }
4. MVI Pattern (this project's pattern)
The project uses MVI with StateFlow<UiState> + Channel<UiEffect> (one-off effects). See references/architecture.md and references/coroutines-flow.md for the full pattern.
class CustomerListViewModel(private val repo: CustomerRepository) : ViewModel() {
private val _uiState = MutableStateFlow(CustomerListUiState())
val uiState: StateFlow<CustomerListUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<CustomerListEvent>(extraBufferCapacity = 1)
val events: SharedFlow<CustomerListEvent> = _events.asSharedFlow()
fun onIntent(intent: CustomerListIntent) { ... }
}
@Composable
fun CustomerListScreen(viewModel: CustomerListViewModel = metroViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is CustomerListEvent.NavigateTo -> { ... }
is CustomerListEvent.ShowError -> { ... }
}
}
}
}
Anti-patterns in MVI
val customers = repo.getCustomers()
if (user.role == "admin") showAdminPanel()
val state by viewModel.uiState.collectAsState()
val _events = MutableSharedFlow<Event>()
5. KMP Platform Compatibility in Compose
What breaks on non-JVM targets (iOS / Wasm)
LocalContext.current
context.getSystemService(...)
rememberLauncherForActivityResult(...)
BackHandler { }
val fmt = SimpleDateFormat("yyyy-MM-dd")
System.currentTimeMillis()
expect @Composable fun BackNavigationHandler(onBack: () -> Unit)
actual @Composable fun BackNavigationHandler(onBack: () -> Unit) {
BackHandler(onBack = onBack)
}
actual @Composable fun BackNavigationHandler(onBack: () -> Unit) { }
Dispatchers on iOS
Dispatchers.IO is available on all KMP targets since kotlinx.coroutines 1.7+. This project uses 1.10.2, so it's safe everywhere. The old Dispatchers.Default workaround is no longer needed.
withContext(Dispatchers.IO) { ... }
6. Performance
Lazy lists
LazyColumn {
items(customers, key = { it.uid }) { customer ->
CustomerRow(customer = customer)
}
}
LazyColumn {
items(feed, key = { it.id }, contentType = { it::class }) { ... }
}
items(list, key = { index, _ -> index }) { ... }
Image loading (Coil 3)
AsyncImage(
model = ImageRequest.Builder(LocalPlatformContext.current)
.data(url)
.size(64.dp.toPx().toInt())
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(64.dp)
)
AsyncImage(model = url, contentDescription = null)
Modifier order matters
Modifier.clickable { }.padding(8.dp)
Modifier.padding(8.dp).clickable { }
Avoid allocation in composition
Text(style = TextStyle(color = Color.Red))
val errorStyle = remember { TextStyle(color = Color.Red) }
Text(style = errorStyle)
items.forEach { item ->
Button(onClick = { onItemClick(item) }) { ... }
}
7. Navigation3 (this project)
This project uses Navigation3 (androidx.navigation3, alpha). Routes implement NavKey via @Serializable sealed interface.
@Serializable data object CustomerList : CustomerRoute
@Serializable data class CustomerDetail(val uid: String) : CustomerRoute
class CustomerEntryProvider : NavEntryProvider {
override fun NavEntry.Companion.provideEntry(key: NavKey): NavEntry? = when (key) {
is CustomerRoute.CustomerList -> NavEntry(key) { CustomerListScreen() }
is CustomerRoute.CustomerDetail -> NavEntry(key) { CustomerDetailScreen(uid = key.uid) }
else -> null
}
}
navController.navigate(CustomerRoute.CustomerList)
NavEntry(key) { vm ->
CustomerListScreen(viewModel = vm)
}
8. Accessibility
IconButton(onClick = { ... }) {
Icon(Icons.Default.Edit, contentDescription = stringResource(Res.string.edit))
}
Box(
modifier = Modifier.semantics {
contentDescription = "Customer card: ${customer.name}"
role = Role.Button
}
)
Icon(Icons.Default.Delete, contentDescription = null)
9. String Resources (KMP rule)
All user-visible strings must come from Compose resources — never hardcoded.
Text(text = stringResource(Res.string.customer_list_title))
val msg = getString(Res.string.error_network)
Text(text = "Customer List")
context.getString(R.string.customer_list_title)
Strings go in: feature/{name}/src/commonMain/composeResources/values/strings.xml
Import: import ampairsapp.{module.path}.generated.resources.*
Publishing + Compose Resources — pin packageOfResClass
When a module that has a composeResources/ directory (i.e. source files import ampairsapp.*) is given group = "..." for Maven publishing, CMP shifts the auto-derived accessor package away from the project-path-based name. This breaks all existing resource imports at compile time.
Fix: always add compose.resources { packageOfResClass } whenever adding maven-publish to a module that uses Compose resources.
group = "com.ampairs"
version = "1.0.0"
compose.resources {
packageOfResClass = "ampairsapp.{module.path}.generated.resources"
}
How to derive the package for any module: take the Gradle project path (e.g. :feature:customer), drop the leading colon, replace remaining colons with dots, append .generated.resources → ampairsapp.feature.customer.generated.resources.
Check before adding publishing to any module:
- Does
src/commonMain/composeResources/ exist? → pin required
- Do any source files import
ampairsapp.*? → pin required
- Neither → no pin needed
10. Theming
Text(color = MaterialTheme.colorScheme.onSurface)
Surface(color = MaterialTheme.colorScheme.surfaceVariant)
PlatformAmpairsTheme(darkTheme = themeManager.isDarkTheme())
Text(color = Color(0xFF333333))
LocalAppGraph.current.themeManager
11. Checklist Before Committing Compose Code
12. Currency & Locale Formatting (workspace business locale)
Money and (soon) dates render using the active workspace's business locale — currency, timezone,
date/time format from the business profile — never hardcoded. Storage/sync stay UTC; this is a
display-only concern.
How it flows
business profile (timezone/date_format/time_format/currency)
→ BusinessLocaleProvider (@SingleIn(WorkspaceScope::class), feature/business) : Flow<AppLocale>
→ exposed on WorkspaceGraph.businessLocaleProvider
→ AppNavigationNav3 collects it from the ACTIVE workspace graph and provides LocalAppLocale
→ any @Composable reads LocalAppLocale.current (defaults to AppLocale.Default = INR/UTC)
Sourcing from workspaceSession.graph (recreated per workspace) means the locale is never stale
after a workspace switch — do not resolve it via a root-level ViewModel.
Money — com.ampairs.common.locale
val locale = LocalAppLocale.current
Text(formatMoney(amount, locale))
Text(currencySymbol(locale.currencyCode))
Text("₹$amount")
Text(amount.toInr())
Text(amount.asRupee())
formatMoney(amount: Double?, locale) / formatMoney(amount, currencyCode) — symbol + grouping, 2 dp, null → "".
- Read
LocalAppLocale.current only in @Composable scope (it's a CompositionLocal). A non-composable
string builder (e.g. print HTML) must take a currencySymbol: String parameter passed from the
calling composable — see buildInvoiceHtml(...).
Dates — com.ampairs.common.locale
val locale = LocalAppLocale.current
Text(formatDate(invoice.invoiceDate, locale))
Text(formatDateTime(isoString, locale))
Text(instant.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString())
Text(DateTimeFormatter.formatTimestamp(iso))
formatDate / formatTime / formatDateTime(instant|iso, locale) convert a UTC Instant to
AppLocale.timeZoneId and render per dateFormat (DD-MM-YYYY / MM-DD-YYYY / YYYY-MM-DD; friendly
dd MMM yyyy fallback) and timeFormat (12H/24H).
The computation trap (not just display): bucketing an Instant to a calendar day/month with
TimeZone.currentSystemDefault() is wrong when the business timezone differs from the device — it
can land on the wrong day/month. In non-composable code (ViewModel/repository/print) that can't read
LocalAppLocale, inject the business timezone (via BusinessLocaleProvider) and convert with
TimeZone.of(locale.timeZoneId). Known remaining spots: InvoiceViewModel.formatDocDate(),
OrderViewModel order-date, SubscriptionRepository monthly-usage bucketing.
Quick Anti-Pattern Reference
| Anti-pattern | Fix |
|---|
collectAsState() | collectAsStateWithLifecycle() |
LocalContext.current in commonMain | expect/actual or pass as param |
BackHandler in commonMain | expect/actual BackNavigationHandler |
Dispatchers.IO before coroutines 1.7 | Safe since 1.7+ — project uses 1.10.2, no issue |
Hardcoded string in Text() | stringResource(Res.string.xxx) |
LocalAppGraph.current in composable | Metro-injected ViewModel |
| Business logic in composable | Move to ViewModel intent handler |
LazyList without key | items(list, key = { it.id }) |
| Unstable lambda in recomposition | remember(key) { { ... } } |
List<T> param causing instability | @Immutable wrapper or ImmutableList |
| State in composable that caller needs | Hoist state up |
derivedStateOf outside remember | remember { derivedStateOf { } } |
SideEffect for async work | LaunchedEffect |
LaunchedEffect(Unit) for key-dependent work | LaunchedEffect(theKey) |
| Padding before clickable | clickable { }.padding(...) |
| New object allocation in composition | remember { } |
Hardcoded ₹ / toInr() / asRupee() in UI | formatMoney(amount, LocalAppLocale.current) (see §12) |
maven-publish added, resource imports broken | Add compose.resources { packageOfResClass = "ampairsapp.{module.path}.generated.resources" } |