State management is the central challenge of building reactive user interfaces. In Jetpack Compose, the UI is a function of state: when state changes, the UI automatically recomposes to reflect those changes. Understanding how to model, store, and update state correctly determines whether your Compose code is elegant and performant or tangled and sluggish.
This guide explores state management in Jetpack Compose from foundational concepts through advanced patterns used in complex production applications. We examine how state flows through Compose hierarchies, how to choose appropriate state holders, and how to avoid common pitfalls that cause unnecessary recomposition or state inconsistencies.
The Fundamentals of State in Compose
At its core, Compose distinguishes between stateless and stateful composables. A stateless composable is a pure function of its parameters: given the same inputs, it always produces the same output. It holds no internal state and relies entirely on callers to provide data and handle events.
A stateful composable manages its own state internally. It remembers values across recompositions and can modify those values in response to events. Stateful composables are convenient but harder to test, preview, and reuse because their behavior depends on internal state that callers cannot control.
The remember function preserves values across recompositions. Without remember, every recomposition would reset local variables to their initial values. With remember, Compose stores the value in the composition and returns the same instance on subsequent recompositions.
@Composable
fun Counter() {
// This count survives recomposition
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
The mutableStateOf function creates observable state that triggers recomposition when modified. Compose tracks which composables read which state objects and recomposes only the affected parts of the UI when state changes. This selective recomposition is fundamental to Compose’s performance model.
State hoisting is the practice of moving state up the composable hierarchy to make components stateless and reusable. Instead of a composable managing its own state, the caller provides the state value and a callback to request changes. This pattern appears throughout well-designed Compose code.
// Stateful version - harder to test and reuse
@Composable
fun SearchField() {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search...") }
)
}
// Stateless version - caller controls state
@Composable
fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search...") },
modifier = modifier
)
}
Choosing Where State Lives
One of the most important architectural decisions in Compose applications is determining where state should live. State that lives too high creates unnecessary coupling. State that lives too low cannot be shared where needed. Finding the right level requires understanding the scope and lifetime of different state types.
UI element state like scroll position, text field focus, and animation progress typically belongs in composables using remember. This state is inherently tied to specific UI elements and does not need to survive configuration changes or be shared across screens.
Screen state like form field values, filter selections, and loading indicators typically belongs in a ViewModel. ViewModels survive configuration changes, can be scoped to navigation destinations, and provide a clear separation between UI and business logic. Compose’s viewModel() function retrieves ViewModels with appropriate scoping.
Application state like user authentication, theme preferences, and cart contents spans multiple screens and typically lives in repositories or application-scoped state holders. This state outlives any single screen and must be accessible throughout the application.
// ViewModel holding screen state
class ProductListViewModel(
private val productRepository: ProductRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductListUiState())
val uiState: StateFlow = _uiState.asStateFlow()
init {
loadProducts()
}
fun onSearchQueryChange(query: String) {
_uiState.update { it.copy(searchQuery = query) }
searchProducts(query)
}
fun onFilterChange(filter: ProductFilter) {
_uiState.update { it.copy(activeFilter = filter) }
applyFilter(filter)
}
private fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
productRepository.getProducts()
.onSuccess { products ->
_uiState.update { it.copy(
isLoading = false,
products = products
)}
}
.onFailure { error ->
_uiState.update { it.copy(
isLoading = false,
error = error.message
)}
}
}
}
}
data class ProductListUiState(
val isLoading: Boolean = false,
val products: List = emptyList(),
val searchQuery: String = "",
val activeFilter: ProductFilter = ProductFilter.All,
val error: String? = null
)
StateFlow vs Compose State
ViewModels can expose state using either Kotlin Flow (typically StateFlow) or Compose’s mutableStateOf. Both approaches work, but they have different characteristics that influence the choice.
StateFlow integrates with Kotlin’s broader reactive ecosystem. It works naturally with coroutines, can be combined with other flows using operators, and can be collected in non-Compose contexts. State exposed as StateFlow feels familiar to developers experienced with reactive programming patterns.
Compose state integrates more directly with Compose’s recomposition system. Reading a mutableStateOf property automatically subscribes the composable to updates. The syntax is more concise, and state changes are immediately reflected without explicit collection.
// Collecting StateFlow in Compose
@Composable
fun ProductListScreen(viewModel: ProductListViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ProductListContent(
products = uiState.products,
isLoading = uiState.isLoading,
onProductClick = { /* navigate */ }
)
}
// Using Compose state directly from ViewModel
class ProductListViewModel : ViewModel() {
var uiState by mutableStateOf(ProductListUiState())
private set
fun onSearchQueryChange(query: String) {
uiState = uiState.copy(searchQuery = query)
}
}
The collectAsStateWithLifecycle extension function from the lifecycle-runtime-compose library is essential when collecting flows in Compose. It properly handles lifecycle events, stopping collection when the composable leaves composition and avoiding memory leaks.
Modeling Complex UI State
As screens grow more complex, state modeling decisions significantly impact code clarity and correctness. Sealed classes and sealed interfaces elegantly represent mutually exclusive states that screens can inhabit.
sealed interface ProductDetailsUiState {
object Loading : ProductDetailsUiState
data class Success(
val product: Product,
val relatedProducts: List,
val reviews: List,
val canAddToCart: Boolean,
val isAddingToCart: Boolean = false
) : ProductDetailsUiState
data class Error(
val message: String,
val canRetry: Boolean = true
) : ProductDetailsUiState
}
@Composable
fun ProductDetailsScreen(viewModel: ProductDetailsViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is ProductDetailsUiState.Loading -> LoadingIndicator()
is ProductDetailsUiState.Success -> ProductDetailsContent(
product = state.product,
relatedProducts = state.relatedProducts,
reviews = state.reviews,
onAddToCart = viewModel::onAddToCartClick,
isAddingToCart = state.isAddingToCart
)
is ProductDetailsUiState.Error -> ErrorMessage(
message = state.message,
onRetry = if (state.canRetry) viewModel::retry else null
)
}
}
This pattern makes impossible states unrepresentable. You cannot accidentally show loading and error simultaneously. The compiler ensures you handle all cases in when expressions. State transitions become explicit and traceable.
Handling Side Effects
Not all responses to state changes should trigger recomposition. Sometimes you need to perform side effects: showing a snackbar, navigating to another screen, logging analytics, or triggering one-time events. Compose provides effect handlers for these scenarios.
LaunchedEffect runs a suspend function when the composable enters composition and cancels it when leaving. It relaunches if its key parameters change. Use LaunchedEffect for coroutine-based side effects triggered by state.
@Composable
fun ProductListScreen(
viewModel: ProductListViewModel,
onNavigateToProduct: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Navigate when navigationEvent changes
LaunchedEffect(uiState.navigationEvent) {
uiState.navigationEvent?.let { event ->
when (event) {
is NavigationEvent.ProductDetails -> {
onNavigateToProduct(event.productId)
viewModel.onNavigationHandled()
}
}
}
}
ProductListContent(/* ... */)
}
For truly one-time events that should not be replayed on configuration changes, consider using Channels or SharedFlow with replay = 0. The ViewModel emits events to a channel, and the composable collects them once without storing them in state that would re-trigger on recomposition.
SideEffect runs after every successful recomposition. Use it sparingly for non-compose code that needs to stay synchronized with compose state, like updating analytics or external state holders.
DisposableEffect handles cleanup when a composable leaves composition. It is essential for registering and unregistering listeners, observers, or callbacks.
Performance Optimization
Compose’s recomposition is efficient, but unnecessary recomposition still wastes resources and can cause visible jank in complex UIs. Understanding what triggers recomposition helps you structure code to minimize unnecessary work.
Compose skips recomposition when all parameters to a composable are stable and have not changed. Primitive types, strings, and immutable data classes are stable by default. Lambda parameters are stable if they do not capture unstable values. Collections like List and Map are not considered stable because their contents might change without the reference changing.
The @Stable and @Immutable annotations explicitly mark types as stable, enabling Compose to skip recomposition more aggressively. Use @Immutable for truly immutable types and @Stable for types that notify Compose when they change through observable properties.
@Immutable
data class Product(
val id: String,
val name: String,
val price: Double,
val imageUrl: String
)
@Stable
class CartState {
var items by mutableStateOf(listOf())
private set
var total by mutableStateOf(0.0)
private set
fun addItem(item: CartItem) {
items = items + item
total = items.sumOf { it.price * it.quantity }
}
}
Derive state calculations using derivedStateOf when one state value depends on others but should not trigger its own recomposition. The derived state only triggers reading composables when the calculated result actually changes, not when the source states change.
@Composable
fun SearchableList(items: List- , searchQuery: String) {
// Only recalculated when items or searchQuery change
// Only triggers recomposition when the filtered result changes
val filteredItems by remember(items, searchQuery) {
derivedStateOf {
if (searchQuery.isEmpty()) items
else items.filter { it.name.contains(searchQuery, ignoreCase = true) }
}
}
LazyColumn {
items(filteredItems) { item ->
ItemRow(item)
}
}
}
Use remember with appropriate keys to cache expensive computations. The computation only re-runs when the keys change, not on every recomposition.
Testing State Management
Testable state management is good state management. Hoisting state to ViewModels makes business logic testable without UI infrastructure. Stateless composables can be tested in isolation with predetermined inputs.
ViewModel tests verify that state updates correctly in response to actions and that side effects trigger appropriately. Use test dispatchers to control coroutine execution and test turbine or similar libraries to verify flow emissions.
@Test
fun `onSearchQueryChange updates state and triggers search`() = runTest {
val repository = FakeProductRepository()
val viewModel = ProductListViewModel(repository)
viewModel.uiState.test {
// Initial state
assertEquals(ProductListUiState(), awaitItem())
// Trigger search
viewModel.onSearchQueryChange("phone")
// Verify query updated
val stateWithQuery = awaitItem()
assertEquals("phone", stateWithQuery.searchQuery)
// Verify loading state
assertTrue(awaitItem().isLoading)
// Verify results
val finalState = awaitItem()
assertFalse(finalState.isLoading)
assertTrue(finalState.products.all { "phone" in it.name.lowercase() })
}
}
Compose UI tests verify that composables render correctly given specific states and respond appropriately to user interaction. The compose testing library provides APIs for asserting UI state and simulating user actions.
Conclusion
State management in Jetpack Compose requires understanding both the framework’s reactive model and general principles of state architecture. Start with state hoisting to create testable, reusable composables. Use ViewModels for screen-level state that survives configuration changes. Model complex states with sealed classes to make impossible states unrepresentable. Optimize performance by understanding what triggers recomposition and using appropriate annotations and derived state.
The patterns described here scale from simple screens to complex applications with hundreds of composables. Consistency in state management approach across a codebase makes the code predictable and maintainable as it grows.
At RyuPy, Jetpack Compose is our preferred approach for building Android UI. The declarative model with proper state management produces code that is easier to write, easier to understand, and easier to maintain than traditional imperative UI code. We encourage every Android developer to invest in mastering these patterns.

0 Comments