Jetpack Compose: State Management Best Practices
Production-tested state management patterns for Jetpack Compose: remember, state hoisting, unidirectional data flow, ViewModel with StateFlow and derivedStateOf.
State management in Jetpack Compose decides how data flows through your UI — and whether your app feels smooth and predictable or buggy and hard to change. In this guide, I share the state management best practices I use in production Compose apps: remember, state hoisting, unidirectional data flow, ViewModels with StateFlow, and the mistakes to avoid.
Just getting started with Compose? Read the beginner-friendly Jetpack Compose Buttons and State first.
What Is State in Jetpack Compose?
In Compose, state is any value that can change over time and affects what the UI shows: a text field's content, a loading flag, a list of items.
Compose UI is a function of state:
UI = f(state)
When state changes, Compose recomposes — it re-runs the composables that read that state and updates only what changed. You never update views manually.
remember and mutableStateOf
The simplest state lives inside a composable:
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
mutableIntStateOf(ormutableStateOffor other types) creates observable state.rememberkeeps it across recompositions.rememberSaveablealso keeps it across configuration changes (rotation) and process death, for simple types.
Stateful vs Stateless Composables
A stateful composable owns its state. A stateless composable receives state as parameters and reports events through callbacks.
Stateless composables are:
- Reusable – the same component works anywhere.
- Testable – pass in any state and assert the output.
- Previewable – easy to render in
@Previewwith sample data.
Aim to make most of your composables stateless.
State Hoisting
State hoisting means moving state up to the caller, and passing down value + onValueChange. Hoist state to the lowest common ancestor of all composables that read or write it.
@Composable
fun SearchScreen() {
var searchQuery by rememberSaveable { mutableStateOf("") }
Column {
SearchBar(
query = searchQuery,
onQueryChange = { searchQuery = it }
)
SearchResults(query = searchQuery)
}
}
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier
)
}
SearchBar is now stateless. SearchScreen owns the state because both SearchBar and SearchResults need it.
Unidirectional Data Flow (UDF)
Good Compose apps follow unidirectional data flow:
- State flows down – from ViewModel to screen to child composables.
- Events flow up – from composables to the ViewModel through callbacks.
The UI never modifies state directly; it asks the state holder to change it. This makes bugs much easier to trace, because there is exactly one place where each piece of state changes.
ViewModel + StateFlow for Screen State
For screen-level state and business logic, use a ViewModel that exposes a single immutable UI state:
data class SearchUiState(
val query: String = "",
val results: List<Product> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class SearchViewModel @Inject constructor(
private val searchProducts: SearchProductsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
fun onQueryChange(query: String) {
_uiState.update { it.copy(query = query) }
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(300) // debounce typing
_uiState.update { it.copy(isLoading = true, error = null) }
searchProducts(query)
.onSuccess { list -> _uiState.update { it.copy(results = list, isLoading = false) } }
.onFailure { e -> _uiState.update { it.copy(error = e.message, isLoading = false) } }
}
}
}
Then collect it in the screen, and pass plain values down:
@Composable
fun SearchRoute(viewModel: SearchViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
SearchContent(state = state, onQueryChange = viewModel::onQueryChange)
}
@Composable
fun SearchContent(state: SearchUiState, onQueryChange: (String) -> Unit) {
Column {
SearchBar(query = state.query, onQueryChange = onQueryChange)
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> Text("Error: ${state.error}")
else -> LazyColumn {
items(state.results, key = { it.id }) { ProductRow(it) }
}
}
}
}
Why this pattern works:
- One source of truth – the whole screen is described by
SearchUiState. update { it.copy(...) }changes state atomically and safely.collectAsStateWithLifecycle()stops collecting when the app is in the background, saving battery and work. (It comes fromandroidx.lifecycle:lifecycle-runtime-compose.)SearchContentis stateless, so it's trivial to preview and test.
The searchProducts use case comes from the domain layer — see Building Clean Architecture in Android for how that layer is structured.
remember vs rememberSaveable vs ViewModel
| Use | For |
|---|---|
remember | Temporary UI state (animation flags, expanded/collapsed) that can be lost on rotation |
rememberSaveable | Simple UI state that should survive rotation and process death (text input, selected tab) |
| ViewModel | Screen state, business logic, data from repositories |
derivedStateOf: Avoid Extra Recompositions
When state is computed from other state that changes often, use derivedStateOf so recomposition only happens when the result actually changes:
val listState = rememberLazyListState()
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Without it, the composable would recompose on every scroll pixel.
Common Mistakes to Avoid
- Mutable collections as state.
mutableListOf()changes don't trigger recomposition. Use immutable lists withcopy(), ormutableStateListOf(). - Forgetting
remember. State created without it resets on every recomposition. - Passing the ViewModel down the tree. Pass state and lambdas instead; only the top-level "route" composable should know the ViewModel.
- Many separate StateFlows per screen. Prefer one
UiStatedata class so state stays consistent. - Doing work in composition. Never call network or database code directly in a composable; use
LaunchedEffector the ViewModel.
Frequently Asked Questions
Should I use StateFlow or Compose State in a ViewModel?
Both work. StateFlow is framework-agnostic, easy to test, and plays well with coroutines and Flow operators, so it's my default.
What is the difference between collectAsState and collectAsStateWithLifecycle?
collectAsStateWithLifecycle pauses collection when the lifecycle is below STARTED (for example, when the app is in the background). It's the recommended option on Android.
How do I handle one-time events like showing a snackbar?
Model them as state (for example, a userMessage: String? in UiState that the UI clears after showing it). This avoids lost events during configuration changes.
Conclusion
Keep composables stateless, hoist state to where it's needed, let state flow down and events flow up, and give each screen one immutable UI state in a ViewModel. Start simple with remember, and add a ViewModel as soon as real logic or data appears. To learn the coroutine side of this pattern, continue with Kotlin Coroutines: From Basics to Advanced Patterns.