State Hoisting Patterns in Compose: When, Why, and How

By Engineering Team • Published: 2025-12-11 • Updated: 2025-12-1111 min read

State ManagementArchitectureBest PracticesReusability

What Is State Hoisting?

State hoisting is a pattern where you move state up from a composable to its caller, making the composable stateless. The composable receives state as parameters and notifies the caller of changes via callbacks.

This pattern is fundamental to Compose. It makes components reusable, testable, and previewable. Material components like TextField are designed this way.

The Basic Pattern

A stateless composable receives two things: the current value and an onValueChange callback:

// Stateful version - owns its state, less reusable
@Composable
fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

// Stateless version - state is hoisted, fully reusable
@Composable
fun StatelessCounter(
    count: Int,
    onIncrement: () -> Unit
) {
    Button(onClick = onIncrement) {
        Text("Count: $count")
    }
}

// Usage - caller owns the state
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    StatelessCounter(
        count = count,
        onIncrement = { count++ }
    )
}

When to Hoist State

Not all state needs hoisting. Use this decision tree:

  • Hoist if multiple composables need to read or write the state
  • Hoist if a parent needs to control or observe the state
  • Hoist if the state affects business logic or needs persistence
  • Keep local if it is purely UI-internal (like scroll position or hover state)

State Holder Pattern

For complex state with multiple related values, create a state holder class:

// State holder for a search feature
class SearchState(
    initialQuery: String = "",
    initialFilters: Set<Filter> = emptySet()
) {
    var query by mutableStateOf(initialQuery)
        private set
    var filters by mutableStateOf(initialFilters)
        private set
    var isSearching by mutableStateOf(false)
        private set
    
    val hasActiveFilters: Boolean
        get() = filters.isNotEmpty()
    
    fun updateQuery(newQuery: String) {
        query = newQuery
    }
    
    fun toggleFilter(filter: Filter) {
        filters = if (filter in filters) {
            filters - filter
        } else {
            filters + filter
        }
    }
}

@Composable
fun rememberSearchState(): SearchState {
    return remember { SearchState() }
}

// Usage
@Composable
fun SearchScreen() {
    val searchState = rememberSearchState()
    
    SearchBar(
        query = searchState.query,
        onQueryChange = searchState::updateQuery,
        hasFilters = searchState.hasActiveFilters
    )
}

Hoisting to ViewModel

For state that survives configuration changes or needs to trigger business logic, hoist to ViewModel:

class CartViewModel : ViewModel() {
    private val _items = MutableStateFlow<List<CartItem>>(emptyList())
    val items: StateFlow<List<CartItem>> = _items.asStateFlow()
    
    val totalPrice: StateFlow<BigDecimal> = _items
        .map { items -> items.sumOf { it.price * it.quantity } }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), BigDecimal.ZERO)
    
    fun addItem(item: CartItem) {
        _items.update { current -> current + item }
    }
}

@Composable
fun CartScreen(viewModel: CartViewModel = viewModel()) {
    val items by viewModel.items.collectAsStateWithLifecycle()
    val total by viewModel.totalPrice.collectAsStateWithLifecycle()
    
    CartContent(
        items = items,
        total = total,
        onAddItem = viewModel::addItem
    )
}
Note: The rule of thumb: UI state lives in composables, business state lives in ViewModels. State hoisting connects them cleanly.
← Back to Blog