androidengineers.Book a session

Jetpack Compose System Design

State Hoisting & UDF in Compose

article20 minMedium

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 updated UiState
  • 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

PatternRule
State hoistingMove state up to the lowest common ancestor
Stateless composablesPass state + event callbacks in; emit events out
UiStateSingle data class describing everything the screen needs
UiEventSealed class of all user actions
ViewModel → StateFlowSurvives configuration changes; backpressure-safe
One-time eventsUse SharedFlow (not StateFlow) for navigation/snackbars

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
State Hoisting & UDF in Compose | Android System Design | Android Engineers