8.3 KiB
Compose State Management Patterns
Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see kotlin-expert skill.
Core State Functions
remember
Cache values across recompositions:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
When to use: Simple UI state (toggles, counters, text input) Visual pattern: Button press → state changes → UI updates
derivedStateOf
Compute state from other state, recompose only when result changes:
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// Only recomposes when showButton value changes (not every scroll pixel)
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Default.ArrowUpward, null)
}
}
}
When to use: Input state changes frequently, but derived result changes rarely Visual pattern: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility Performance: Prevents recomposition on every scroll event
produceState
Convert non-Compose state into Compose state:
@Composable
fun LoadUserProfile(userId: String): State<User?> {
return produceState<User?>(initialValue = null, userId) {
value = repository.fetchUser(userId)
}
}
@Composable
fun ProfileScreen(userId: String) {
val user by LoadUserProfile(userId)
when (user) {
null -> LoadingState("Loading profile...")
else -> ProfileCard(user!!)
}
}
When to use: Convert Flow, LiveData, callbacks into Compose state Visual pattern: Async operation → state updates → UI reflects changes Lifecycle: Coroutine cancelled when composable leaves composition
State Hoisting Pattern
Move state up to make composables reusable and testable:
Before (Stateful)
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search...") }
)
}
❌ Hard to test, can't control state externally
After (Stateless)
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search...") },
modifier = modifier
)
}
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
Column {
SearchBar(query = query, onQueryChange = { query = it })
SearchResults(query = query)
}
}
✅ Reusable, testable, state controlled by parent
Hoisting principle: State goes up, events go down
- State:
query: String(read-only) - Events:
onQueryChange: (String) -> Unit(write-only)
Amethyst State Patterns
Theme-Aware State
@Composable
private fun isLightTheme(): Boolean {
val background = MaterialTheme.colorScheme.background
return (background.red + background.green + background.blue) / 3 > 0.5f
}
@Composable
fun ThemedContent() {
val isDark = !isLightTheme()
// Adjust visuals based on theme
val iconTint = if (isDark) Color.White else Color.Black
}
Pattern: Derive state from MaterialTheme Visual: Component adapts to light/dark theme automatically
Relay Status State
@Composable
fun RelayStatusIndicator(
connectedCount: Int,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
val statusColor = when {
connectedCount == 0 -> RelayStatusColors.Disconnected
connectedCount < 3 -> RelayStatusColors.Connecting
else -> RelayStatusColors.Connected
}
Icon(
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
tint = statusColor
)
}
Pattern: Visual state derived from domain state Visual mapping:
- 0 relays → Red + X icon
- 1-2 relays → Yellow + Check icon
- 3+ relays → Green + Check icon
Loading/Empty/Error States
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UiState.Loading -> LoadingState("Loading feed...")
is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() })
is UiState.Error -> FeedErrorState(
errorMessage = uiState.message,
onRetry = { viewModel.retry() }
)
is UiState.Success -> LazyColumn {
items(uiState.items) { FeedItem(it) }
}
}
}
Pattern: Sealed class → visual state component Components:
LoadingState- Progress indicatorEmptyState- Empty message + refreshErrorState- Error message + retry- Success - Actual content
Common Patterns
Toggle State
var isExpanded by remember { mutableStateOf(false) }
IconButton(onClick = { isExpanded = !isExpanded }) {
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
if (isExpanded) {
Text("Expanded content...")
}
List State with Actions
var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) }
Column {
AddButton(onClick = {
items = items + "Item ${items.size + 1}"
})
items.forEachIndexed { index, item ->
Row {
Text(item)
RemoveButton(onClick = {
items = items.filterIndexed { i, _ -> i != index }
})
}
}
}
TextField State
var text by remember { mutableStateOf("") }
TextField(
value = text,
onValueChange = { text = it },
label = { Text("Enter text") }
)
Performance Patterns
Avoid Unnecessary Recomposition
// ❌ Bad: Recomposes on every scroll position change
@Composable
fun BadScrollButton(scrollState: ScrollState) {
if (scrollState.value > 100) { // scrollState.value changes constantly
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
}
}
// ✅ Good: Only recomposes when visibility changes
@Composable
fun GoodScrollButton(scrollState: ScrollState) {
val showButton by remember {
derivedStateOf { scrollState.value > 100 }
}
if (showButton) {
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
}
}
Stable Parameters
Use @Immutable data classes (see kotlin-expert) to prevent recomposition:
@Immutable
data class UserProfile(val name: String, val avatar: String)
@Composable
fun ProfileCard(profile: UserProfile) {
// Only recomposes when profile instance changes
Row {
RobohashImage(robot = profile.avatar)
Text(profile.name)
}
}
Integration with Kotlin State
For ViewModel state, Flow, StateFlow → See kotlin-expert skill
Common integration pattern:
// ViewModel (Kotlin state)
class FeedViewModel {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}
// Composable (Compose state)
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val uiState by viewModel.uiState.collectAsState()
// Use uiState to render UI
}
Quick Reference
| Function | Use Case | Recomposes When |
|---|---|---|
remember { mutableStateOf() } |
Local UI state | State value changes |
derivedStateOf { } |
Computed state | Derived result changes |
produceState { } |
Async/Flow → State | Async operation updates value |
collectAsState() |
Flow → State | Flow emits new value |
| State hoisting | Reusable components | Parent passes new state |
Sources
State management patterns based on:
- State and Jetpack Compose - Android Developers
- When should I use derivedStateOf?
- Advanced State and Side Effects
- AmethystMultiplatform codebase patterns (2025)