State hoisting and Unidirectional Data Flow (UDF) are the architectural patterns that make Compose UIs testable, reusable, and predictable. The core idea: state flows down, events flow up.
The Problem: Stateful Composables
// ❌ Stateful composable — tightly couples state to UI; hard to test or reuse
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(value = query, onValueChange = { query = it })
// How would a parent react to query changes? It can't.
}
State Hoisting: Lift State Up
// ✅ Stateless — state lives outside; composable is pure UI
@Composable
fun SearchBar(
query: String, // state flows DOWN
onQueryChanged: (String) -> Unit // events flow UP
) {
TextField(value = query, onValueChange = onQueryChanged)
}
// Caller owns the state:
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
SearchBar(
query = query,
onQueryChanged = { query = it }
)
}
This follows the rule: hoist state to the lowest common ancestor of composables that need it.
UDF with ViewModel
For real screens, state belongs in a ViewModel. The ViewModel:
- Holds
UiState(sealed class or data class) - Processes
UiEvents and emits updatedUiState - Survives configuration changes
- Is independent of Compose
// UiState — describes everything the UI needs to render
data class ArticleListState(
val articles: List<Article> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val query: String = ""
)
// UiEvent — everything the user can do
sealed class ArticleListEvent {
data class Search(val query: String) : ArticleListEvent()
data class BookmarkArticle(val id: String) : ArticleListEvent()
object Retry : ArticleListEvent()
}
class ArticleListViewModel(
private val repository: ArticleRepository
) : ViewModel() {
private val _state = MutableStateFlow(ArticleListState())
val state: StateFlow<ArticleListState> = _state.asStateFlow()
fun onEvent(event: ArticleListEvent) {
when (event) {
is ArticleListEvent.Search -> search(event.query)
is ArticleListEvent.BookmarkArticle -> bookmark(event.id)
ArticleListEvent.Retry -> retry()
}
}
private fun search(query: String) {
_state.update { it.copy(query = query, isLoading = true) }
viewModelScope.launch {
repository.search(query)
.onSuccess { articles ->
_state.update { it.copy(articles = articles, isLoading = false) }
}
.onFailure { error ->
_state.update { it.copy(error = error.message, isLoading = false) }
}
}
}
}
Composable: Connects ViewModel to UI
@Composable
fun ArticleListScreen(viewModel: ArticleListViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ArticleListContent(
state = state,
onEvent = viewModel::onEvent
)
}
// This composable is pure — no ViewModel dependency, fully testable
@Composable
fun ArticleListContent(
state: ArticleListState,
onEvent: (ArticleListEvent) -> Unit
) {
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> ErrorView(state.error, onRetry = { onEvent(ArticleListEvent.Retry) })
else -> LazyColumn {
items(state.articles) { article ->
ArticleItem(
article = article,
onBookmark = { onEvent(ArticleListEvent.BookmarkArticle(article.id)) }
)
}
}
}
}
One-Time Events (Navigation, Snackbar)
State flows are not ideal for one-time events like navigation or snackbars — they replay on collection:
// Use SharedFlow for one-time events
class ViewModel : ViewModel() {
private val _effect = MutableSharedFlow<UiEffect>()
val effect: SharedFlow<UiEffect> = _effect.asSharedFlow()
private fun onLoginSuccess() {
viewModelScope.launch {
_effect.emit(UiEffect.NavigateToHome)
}
}
}
sealed class UiEffect {
object NavigateToHome : UiEffect()
data class ShowSnackbar(val message: String) : UiEffect()
}
// In composable:
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
UiEffect.NavigateToHome -> navController.navigate("home")
is UiEffect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message)
}
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
| State hoisting | Move state up to the lowest common ancestor |
| Stateless composables | Pass state + event callbacks in; emit events out |
UiState | Single data class describing everything the screen needs |
UiEvent | Sealed class of all user actions |
ViewModel → StateFlow | Survives configuration changes; backpressure-safe |
| One-time events | Use SharedFlow (not StateFlow) for navigation/snackbars |