Skip to content
Android

Jetpack Compose State Hoisting vs ViewModel: When to Use Each

30 June 20267 min read0 views
Jetpack Compose State Hoisting vs ViewModel: When to Use Each
A practical guide to managing state in Jetpack Compose — when to hoist state locally within the composable tree versus lifting it into a ViewModel.

State Management in Compose

Jetpack Compose's reactive UI model means every recomposition is driven by state changes. Knowing where to put that state — inside a composable, hoisted up the tree, or inside a ViewModel — determines testability, lifecycle correctness, and recomposition efficiency.

1. Local Composable State

Use remember and mutableStateOf for ephemeral UI state that has no business logic and dies with the composable:

@Composable
fun ExpandableCard(title: String, content: String) {
    var expanded by remember { mutableStateOf(false) }

    Card(modifier = Modifier.clickable { expanded = !expanded }) {
        Column {
            Text(title)
            AnimatedVisibility(visible = expanded) {
                Text(content)
            }
        }
    }
}

Use when: Toggle state, animation triggers, text field focus — anything that resets when the composable leaves the screen.

2. State Hoisting

State hoisting moves state up to the caller, making the composable stateless and reusable. The pattern follows: move state up, pass events down.

// Stateless composable — easy to test and reuse
@Composable
fun SearchBar(
    query: String,
    onQueryChange: (String) -> Unit
) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Parent manages the state
@Composable
fun SearchScreen() {
    var searchQuery by remember { mutableStateOf("") }
    SearchBar(query = searchQuery, onQueryChange = { searchQuery = it })
}

3. ViewModel State (Screen-Level)

For state that must survive configuration changes (screen rotation), involve business logic, or coordinate with data repositories, use a ViewModel:

class SensorViewModel : ViewModel() {
    private val _readings = MutableStateFlow<List<SensorReading>>(emptyList())
    val readings: StateFlow<List<SensorReading>> = _readings.asStateFlow()

    init {
        viewModelScope.launch {
            repository.observeReadings().collect { _readings.value = it }
        }
    }
}

@Composable
fun SensorScreen(viewModel: SensorViewModel = viewModel()) {
    val readings by viewModel.readings.collectAsStateWithLifecycle()
    SensorList(readings = readings)
}

Decision Rule

  • Local remember → UI-only, ephemeral, composable-scoped
  • Hoisted state → Shared between sibling composables, no business logic
  • ViewModel → Survives rotation, calls repositories, drives network or DB operations

Building production Android apps? Let's talk →

Frequently Asked Questions

Q:Should I always use ViewModel in Compose?

No. Overusing ViewModel adds boilerplate for simple UI state. Use ViewModel only when state needs to survive configuration changes or involves data layer access.

Q:How do I pass ViewModel state to deeply nested composables?

Prefer passing data as parameters down the tree (state hoisting) rather than accessing ViewModel directly in leaf composables. This keeps leaf composables reusable and testable without mocking ViewModels.

Working on something similar?

Let's collaborate to design custom PCB schematics, write deterministic FreeRTOS threads, or configure secure Next.js databases.

Let's talk →