compose-multiplatform-patterns
affaan-m/ecc
State management, navigation, theming, and performance patterns for Compose Multiplatform and Jetpack Compose projects.
What is compose-multiplatform-patterns?
Patterns for building shared UI across Android, iOS, Desktop, and Web using Compose Multiplatform and Jetpack Compose. Covers ViewModel-based state management with StateFlow, type-safe navigation, composable design principles, KMP platform-specific UI, and recomposition optimization.
- Implement single-state-object pattern with ViewModels and StateFlow for predictable UI state
- Use type-safe navigation with @Serializable routes and dialog/bottom-sheet overlays
- Design reusable composables with slot-based APIs and correct modifier ordering
- Apply expect/actual for platform-specific composables in KMP projects
- Optimize recomposition with @Stable/@Immutable types, lazy list keys, and derivedStateOf
- Implement Material 3 dynamic theming with dark mode support
How to install compose-multiplatform-patterns
npx skills add null --skill compose-multiplatform-patternsHow to use compose-multiplatform-patterns
- 1.Review the state management section and refactor your screen state into a single data class exposed as StateFlow
- 2.Define your navigation routes as @Serializable objects and set up type-safe NavHost composables
- 3.Extract stateless composables from your screens and pass state + event callbacks as parameters
- 4.Mark your UI model classes with @Immutable or @Stable to enable recomposition skipping
- 5.Use key() in LazyColumn/LazyRow and derivedStateOf to optimize list rendering
- 6.Apply the modifier ordering pattern (layout → shape → drawing → interaction) to your composables
- 7.For KMP projects, use expect/actual to define platform-specific composables in commonMain
Use cases
- Building a multi-platform app with shared Compose UI across Android, iOS, Desktop, and Web
- Refactoring a Jetpack Compose screen to use ViewModel state instead of local mutable state
- Implementing type-safe navigation in a large Compose project to avoid string-based route errors
- Designing a reusable component library with slot-based composables for flexibility
- Optimizing a list screen with thousands of items to reduce unnecessary recompositions
- Android developers building Jetpack Compose UIs
- Kotlin Multiplatform (KMP) developers sharing UI code across platforms
- UI/UX engineers designing composable component systems
- Performance-focused developers optimizing recomposition and rendering
- Teams migrating from imperative navigation to type-safe declarative navigation
compose-multiplatform-patterns FAQ
Use event sink (sealed interface) for complex screens with many user interactions; it reduces callback parameter count and makes event handling centralized in the ViewModel. For simple screens with 1–2 callbacks, individual lambdas are clearer.
collectAsStateWithLifecycle respects the Compose lifecycle and stops collecting when the composable leaves the composition, preventing memory leaks and unnecessary state updates in the background.
Define route parameters as properties of @Serializable route objects, extract them in the NavHost composable, and pass them as regular function parameters to your screen composables.
Apply in this order: layout (padding, size) → shape (clip) → drawing (background, border) → interaction (clickable). This ensures visual effects layer correctly and interactions work on the final composed layout.
Use key(item.id) in your list items and mark your item data class with @Immutable. This lets Compose reuse composables for unchanged items and only recompose the modified one.
Full instructions (SKILL.md)
Source of truth, from affaan-m/ecc.
name: compose-multiplatform-patterns description: Compose Multiplatform and Jetpack Compose patterns for KMP projects — state management, navigation, theming, performance, and platform-specific UI. metadata: origin: ECC
Compose Multiplatform Patterns
Patterns for building shared UI across Android, iOS, Desktop, and Web using Compose Multiplatform and Jetpack Compose. Covers state management, navigation, theming, and performance.
When to Activate
- Building Compose UI (Jetpack Compose or Compose Multiplatform)
- Managing UI state with ViewModels and Compose state
- Implementing navigation in KMP or Android projects
- Designing reusable composables and design systems
- Optimizing recomposition and rendering performance
State Management
ViewModel + Single State Object
Use a single data class for screen state. Expose it as StateFlow and collect in Compose:
data class ItemListState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val searchQuery: String = ""
)
class ItemListViewModel(
private val getItems: GetItemsUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ItemListState())
val state: StateFlow<ItemListState> = _state.asStateFlow()
fun onSearch(query: String) {
_state.update { it.copy(searchQuery = query) }
loadItems(query)
}
private fun loadItems(query: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
getItems(query).fold(
onSuccess = { items -> _state.update { it.copy(items = items, isLoading = false) } },
onFailure = { e -> _state.update { it.copy(error = e.message, isLoading = false) } }
)
}
}
}
Collecting State in Compose
@Composable
fun ItemListScreen(viewModel: ItemListViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ItemListContent(
state = state,
onSearch = viewModel::onSearch
)
}
@Composable
private fun ItemListContent(
state: ItemListState,
onSearch: (String) -> Unit
) {
// Stateless composable — easy to preview and test
}
Event Sink Pattern
For complex screens, use a sealed interface for events instead of multiple callback lambdas:
sealed interface ItemListEvent {
data class Search(val query: String) : ItemListEvent
data class Delete(val itemId: String) : ItemListEvent
data object Refresh : ItemListEvent
}
// In ViewModel
fun onEvent(event: ItemListEvent) {
when (event) {
is ItemListEvent.Search -> onSearch(event.query)
is ItemListEvent.Delete -> deleteItem(event.itemId)
is ItemListEvent.Refresh -> loadItems(_state.value.searchQuery)
}
}
// In Composable — single lambda instead of many
ItemListContent(
state = state,
onEvent = viewModel::onEvent
)
Navigation
Type-Safe Navigation (Compose Navigation 2.8+)
Define routes as @Serializable objects:
@Serializable data object HomeRoute
@Serializable data class DetailRoute(val id: String)
@Serializable data object SettingsRoute
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> {
HomeScreen(onNavigateToDetail = { id -> navController.navigate(DetailRoute(id)) })
}
composable<DetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DetailRoute>()
DetailScreen(id = route.id)
}
composable<SettingsRoute> { SettingsScreen() }
}
}
Dialog and Bottom Sheet Navigation
Use dialog() and overlay patterns instead of imperative show/hide:
NavHost(navController, startDestination = HomeRoute) {
composable<HomeRoute> { /* ... */ }
dialog<ConfirmDeleteRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ConfirmDeleteRoute>()
ConfirmDeleteDialog(
itemId = route.itemId,
onConfirm = { navController.popBackStack() },
onDismiss = { navController.popBackStack() }
)
}
}
Composable Design
Slot-Based APIs
Design composables with slot parameters for flexibility:
@Composable
fun AppCard(
modifier: Modifier = Modifier,
header: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
actions: @Composable RowScope.() -> Unit = {}
) {
Card(modifier = modifier) {
Column {
header()
Column(content = content)
Row(horizontalArrangement = Arrangement.End, content = actions)
}
}
}
Modifier Ordering
Modifier order matters — apply in this sequence:
Text(
text = "Hello",
modifier = Modifier
.padding(16.dp) // 1. Layout (padding, size)
.clip(RoundedCornerShape(8.dp)) // 2. Shape
.background(Color.White) // 3. Drawing (background, border)
.clickable { } // 4. Interaction
)
KMP Platform-Specific UI
expect/actual for Platform Composables
// commonMain
@Composable
expect fun PlatformStatusBar(darkIcons: Boolean)
// androidMain
@Composable
actual fun PlatformStatusBar(darkIcons: Boolean) {
val systemUiController = rememberSystemUiController()
SideEffect { systemUiController.setStatusBarColor(Color.Transparent, darkIcons) }
}
// iosMain
@Composable
actual fun PlatformStatusBar(darkIcons: Boolean) {
// iOS handles this via UIKit interop or Info.plist
}
Performance
Stable Types for Skippable Recomposition
Mark classes as @Stable or @Immutable when all properties are stable:
@Immutable
data class ItemUiModel(
val id: String,
val title: String,
val description: String,
val progress: Float
)
Use key() and Lazy Lists Correctly
LazyColumn {
items(
items = items,
key = { it.id } // Stable keys enable item reuse and animations
) { item ->
ItemRow(item = item)
}
}
Defer Reads with derivedStateOf
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Avoid Allocations in Recomposition
// BAD — new lambda and list every recomposition
items.filter { it.isActive }.forEach { ActiveItem(it, onClick = { handle(it) }) }
// GOOD — key each item so callbacks stay attached to the right row
val activeItems = remember(items) { items.filter { it.isActive } }
activeItems.forEach { item ->
key(item.id) {
ActiveItem(item, onClick = { handle(item) })
}
}
Theming
Material 3 Dynamic Theming
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
Anti-Patterns to Avoid
- Using
mutableStateOfin ViewModels whenMutableStateFlowwithcollectAsStateWithLifecycleis safer for lifecycle - Passing
NavControllerdeep into composables — pass lambda callbacks instead - Heavy computation inside
@Composablefunctions — move to ViewModel orremember {} - Using
LaunchedEffect(Unit)as a substitute for ViewModel init — it re-runs on configuration change in some setups - Creating new object instances in composable parameters — causes unnecessary recomposition
References
See skill: android-clean-architecture for module structure and layering.
See skill: kotlin-coroutines-flows for coroutine and Flow patterns.
Related skills
More from affaan-m/ecc and the wider catalog.
config-gc
Agent skill from affaan-m/ecc.
configure-ecc
Interactive installer for Everything Claude Code — selectively install skills and rules to user or project directories.
connections-optimizer
Reorganize your X and LinkedIn network with review-first pruning, smart recommendations, and warm outreach in your voice.
content-engine
Create platform-native content for X, LinkedIn, TikTok, YouTube, and newsletters from a single source.
content-hash-cache-pattern
Cache expensive file processing results using SHA-256 content hashes—path-independent, auto-invalidating.
context-budget
Audit Claude Code context consumption and get prioritized token-savings recommendations.