PluginBench
Skill
Pass
Audit score 90

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-patterns
Claude Code
Cursor
Windsurf
Cline

How to use compose-multiplatform-patterns

  1. 1.Review the state management section and refactor your screen state into a single data class exposed as StateFlow
  2. 2.Define your navigation routes as @Serializable objects and set up type-safe NavHost composables
  3. 3.Extract stateless composables from your screens and pass state + event callbacks as parameters
  4. 4.Mark your UI model classes with @Immutable or @Stable to enable recomposition skipping
  5. 5.Use key() in LazyColumn/LazyRow and derivedStateOf to optimize list rendering
  6. 6.Apply the modifier ordering pattern (layout → shape → drawing → interaction) to your composables
  7. 7.For KMP projects, use expect/actual to define platform-specific composables in commonMain

Use cases

Good for
  • 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
Who it's for
  • 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

When should I use the event sink pattern vs. multiple callback lambdas?

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.

Why is collectAsStateWithLifecycle better than collectAsState?

collectAsStateWithLifecycle respects the Compose lifecycle and stops collecting when the composable leaves the composition, preventing memory leaks and unnecessary state updates in the background.

How do I pass data between navigation destinations without using NavController directly in composables?

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.

What is the correct order for applying modifiers?

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.

How do I avoid recomposing a list when only one item changes?

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 mutableStateOf in ViewModels when MutableStateFlow with collectAsStateWithLifecycle is safer for lifecycle
  • Passing NavController deep into composables — pass lambda callbacks instead
  • Heavy computation inside @Composable functions — move to ViewModel or remember {}
  • 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.