Compose Multiplatform Patterns
Created from the original ECC skill. Full credit goes to ECC.
Use these patterns to build shared UI for Android, iOS, desktop, and web. Keep shared code simple. Put platform code in platform source sets.
Use This Skill When
Use this skill when you:
- Build UI with Jetpack Compose or Compose Multiplatform.
- Manage screen state with a ViewModel and
StateFlow. - Add navigation to an Android or KMP app.
- Build reusable UI parts or a design system.
- Fix slow lists or too many recompositions.
- Need different UI code for each platform.
Check the libraries and versions in the project first. Some APIs work only on Android. Some need a recent Compose or Navigation version.
State Management
Use One State Object Per Screen
Keep all screen state in one data class. Expose it as a read-only StateFlow.
data class ItemListState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
val searchQuery: String = ""
)
class ItemListViewModel(
private val getItems: GetItemsUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ItemListState())
val state: StateFlow<ItemListState> = _state.asStateFlow()
private var loadJob: Job? = null
fun onSearch(query: String) {
_state.update {
it.copy(
searchQuery = query,
errorMessage = null
)
}
loadJob?.cancel()
loadJob = viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
getItems(query).fold(
onSuccess = { items ->
_state.update {
it.copy(
items = items,
isLoading = false,
errorMessage = null
)
}
},
onFailure = { error ->
if (error is CancellationException) throw error
_state.update {
it.copy(
isLoading = false,
errorMessage = error.message ?: "Could not load items."
)
}
}
)
}
}
}Cancel the old job when a new search starts. This stops an old result from replacing a newer result.
Do not show raw error text if it may hold private data. Map errors to safe text for the user.
For search boxes, add a short delay or use Flow tools such as debounce and distinctUntilChanged. This avoids one request for every key press.
Collect State With the Screen Life Cycle
On Android, use collectAsStateWithLifecycle():
@Composable
fun ItemListScreen(
viewModel: ItemListViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
ItemListContent(
state = state,
onEvent = viewModel::onEvent
)
}For shared KMP UI, use the life cycle API chosen by the project. If no shared life cycle API exists, collect in the platform UI layer. Do not assume the Android-only function works on every target.
Keep the main UI function free of the ViewModel:
@Composable
fun ItemListContent(
state: ItemListState,
onEvent: (ItemListEvent) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier) {
TextField(
value = state.searchQuery,
onValueChange = {
onEvent(ItemListEvent.SearchChanged(it))
}
)
when {
state.isLoading -> CircularProgressIndicator()
state.errorMessage != null -> {
Text(state.errorMessage)
}
state.items.isEmpty() -> {
Text("No items found.")
}
else -> {
ItemList(
items = state.items,
onDelete = {
onEvent(ItemListEvent.Delete(it))
}
)
}
}
}
}This makes previews and tests easy.
Use One Event Sink for Busy Screens
Use one event type when a screen has many actions:
sealed interface ItemListEvent {
data class SearchChanged(val query: String) : ItemListEvent
data class Delete(val itemId: String) : ItemListEvent
data object Refresh : ItemListEvent
data object ErrorShown : ItemListEvent
}
fun onEvent(event: ItemListEvent) {
when (event) {
is ItemListEvent.SearchChanged -> onSearch(event.query)
is ItemListEvent.Delete -> deleteItem(event.itemId)
ItemListEvent.Refresh -> onSearch(_state.value.searchQuery)
ItemListEvent.ErrorShown -> {
_state.update { it.copy(errorMessage = null) }
}
}
}Use separate callbacks for small parts with one or two actions. Do not add an event type when it makes simple code harder to read.
Keep One-Time Effects Out of Screen State
A toast, snack bar, or navigation action should happen once. Do not store it as a plain Boolean that may run again after a screen change.
Use an event stream, or let the UI call navigation after a clear user action. Make sure an event is not lost while the screen is stopped.
Save user input that must survive process death with SavedStateHandle. A normal ViewModel only survives common screen rebuilds.
Navigation
Use Typed Routes When Supported
With Navigation Compose 2.8 or later, routes can use serializable types:
@Serializable
data object HomeRoute
@Serializable
data class DetailRoute(val id: String)
@Serializable
data object SettingsRoute
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = HomeRoute
) {
composable<HomeRoute> {
HomeScreen(
onOpenItem = { id ->
navController.navigate(DetailRoute(id))
},
onOpenSettings = {
navController.navigate(SettingsRoute)
}
)
}
composable<DetailRoute> { entry ->
val route = entry.toRoute<DetailRoute>()
DetailScreen(
itemId = route.id,
onBack = { navController.popBackStack() }
)
}
composable<SettingsRoute> {
SettingsScreen(
onBack = { navController.popBackStack() }
)
}
}
}Pass IDs in routes. Load full records from a data source. Large objects can break state save and may become old.
Check route input before use. Show an error or go back if an ID is missing or not valid.
Do not pass NavController through many UI layers. Pass small callbacks such as onBack and onOpenItem.
Stop fast double taps from opening the same screen twice. You can disable the button while moving, or check the current route before calling navigate.
Use Dialog Routes for Dialogs
@Serializable
data class ConfirmDeleteRoute(val itemId: String)
NavHost(
navController = navController,
startDestination = HomeRoute
) {
composable<HomeRoute> {
HomeScreen(
onAskToDelete = { itemId ->
navController.navigate(
ConfirmDeleteRoute(itemId)
)
}
)
}
dialog<ConfirmDeleteRoute> { entry ->
val route = entry.toRoute<ConfirmDeleteRoute>()
ConfirmDeleteDialog(
onConfirm = {
viewModel.deleteItem(route.itemId)
navController.popBackStack()
},
onDismiss = {
navController.popBackStack()
}
)
}
}A bottom sheet is not a built-in route in every Compose setup. Use the sheet or navigation library already chosen by the project. Keep its open state in one place. Handle the back button and outside taps.
Composable Design
Use Slot APIs for Reusable Layouts
@Composable
fun AppCard(
modifier: Modifier = Modifier,
header: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit
) {
Card(modifier = modifier) {
Column {
header()
Column(content = content)
Row(
horizontalArrangement = Arrangement.End,
content = actions
)
}
}
}Put modifier near the start. Give it a default value. Apply it to the top UI node.
Do not add slots that no caller needs. Plain values are better for simple text, icons, and flags.
Modifier Order Changes the Result
Modifiers run in order. There is no one order that is right for every case.
This makes the outer padded area not clickable:
Modifier
.padding(16.dp)
.clickable { onClick() }
.background(Color.White)This makes the padded area clickable:
Modifier
.clickable { onClick() }
.padding(16.dp)
.background(Color.White)This keeps the background inside the rounded shape:
Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color.White)Pick the order based on the wanted size, touch area, shape, and drawing.
Keep State in the Right Place
Move state up to the lowest parent that needs it.
Use remember for state that may reset when the UI leaves the screen. Use rememberSaveable for small UI values that should survive common screen rebuilds.
Do not put a Context, controller, open file, or large object in rememberSaveable.
Platform-Specific UI
Use expect and actual only when shared code needs one small platform action:
// commonMain
@Composable
expect fun PlatformStatusBar(darkIcons: Boolean)// androidMain
@Composable
actual fun PlatformStatusBar(darkIcons: Boolean) {
val view = LocalView.current
SideEffect {
WindowCompat.getInsetsController(
view.context.findActivity().window,
view
).isAppearanceLightStatusBars = darkIcons
}
}// iosMain
@Composable
actual fun PlatformStatusBar(darkIcons: Boolean) {
// Call the small UIKit helper used by this project.
}Do not copy large screens into each source set. Share the screen and split out only the parts that must differ.
Platform APIs may change. Use the current API from the project. Do not add an old system UI library only to copy an example.
Performance
Fix Measured Problems First
Use Compose tools and traces to find slow code. Do not add remember, key, @Stable, or @Immutable without a clear reason.
Use Stable UI Models
Use read-only values in UI models:
@Immutable
data class ItemUiModel(
val id: String,
val title: String,
val description: String,
val progress: Float
)Only mark a type @Immutable when all values inside it are also safe and cannot change in place.
Do not mark a type stable to hide mutable lists or fields. A false mark can make the UI show old data.
Prefer read-only or safe immutable lists when they are used by shared state.
Give Lazy List Items Stable Keys
LazyColumn {
items(
items = items,
key = { item -> item.id }
) { item ->
ItemRow(item = item)
}
}Each key must be unique and must not change for that item. Do not use the list index if items can move, be added, or be removed.
Add contentType when a list has very different row types:
items(
items = rows,
key = { it.id },
contentType = { it.type }
) { row ->
RowContent(row)
}Use derivedStateOf for Values That Change Often
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 5
}
}Use this when the source changes more often than the result. For a cheap value that changes at the same rate, a normal local value is clearer.
Move Work Out of Recomposition
val activeItems = remember(items) {
items.filter { it.isActive }
}
LazyColumn {
items(
items = activeItems,
key = { it.id }
) { item ->
ItemRow(
item = item,
onClick = { onItemClick(item.id) }
)
}
}Do not do file work, network work, JSON parsing, or large list work in a composable body.
Use remember only for pure UI work. Put long work in a ViewModel or data layer.
A new lambda is often cheap. Do not make code hard to read just to avoid one. Measure before changing it.
Themes
Keep shared colors in common code. Put Android-only dynamic colors in Android code.
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
colorScheme: ColorScheme = if (darkTheme) {
darkColorScheme()
} else {
lightColorScheme()
},
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}Android can choose a dynamic scheme before calling the shared theme:
@Composable
fun AndroidAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
AppTheme(
darkTheme = darkTheme,
colorScheme = colorScheme,
content = content
)
}Check text contrast in light and dark themes. Also test large text, screen readers, keyboard use, and touch targets.
Full Usage Example
This small screen joins state, events, and UI:
data class CounterState(
val count: Int = 0
)
sealed interface CounterEvent {
data object Add : CounterEvent
data object Reset : CounterEvent
}
class CounterViewModel : ViewModel() {
private val _state = MutableStateFlow(CounterState())
val state: StateFlow<CounterState> = _state.asStateFlow()
fun onEvent(event: CounterEvent) {
when (event) {
CounterEvent.Add -> {
_state.update { it.copy(count = it.count + 1) }
}
CounterEvent.Reset -> {
_state.value = CounterState()
}
}
}
}
@Composable
fun CounterScreen(
viewModel: CounterViewModel
) {
val state by viewModel.state.collectAsStateWithLifecycle()
CounterContent(
state = state,
onEvent = viewModel::onEvent
)
}
@Composable
fun CounterContent(
state: CounterState,
onEvent: (CounterEvent) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Count: ${state.count}")
Button(
onClick = { onEvent(CounterEvent.Add) }
) {
Text("Add")
}
Button(
onClick = { onEvent(CounterEvent.Reset) },
enabled = state.count != 0
) {
Text("Reset")
}
}
}Preview the state-free UI:
@Preview
@Composable
private fun CounterContentPreview() {
AppTheme {
CounterContent(
state = CounterState(count = 3),
onEvent = {}
)
}
}Avoid These Mistakes
- Do not expose
MutableStateFlowfrom a ViewModel. - Do not mix several state systems for the same screen without a clear need.
- Do not pass
NavControllerthrough deep UI code. - Do not start network or file work in a composable body.
- Do not use
LaunchedEffect(Unit)as a general ViewModel start hook. - Do not use a changing value as a lazy list key.
- Do not put full data records in navigation routes.
- Do not mark mutable types as
@Immutable. - Do not assume Android APIs work in common KMP code.
- Do not keep private data in saved UI state.
- Do not change state during composition. Use an event, effect, or ViewModel.
- Do not use
GlobalScope. - Do not catch
CancellationExceptionas a normal error. - Do not add a new object on every recomposition when it causes real extra work. Use
rememberwith the right keys.
Related Skills
- Use
android-clean-architecturefor modules and app layers. - Use
kotlin-coroutines-flowsfor coroutines and Flow patterns.