Skip to content
intermediate Phase 4 · Jetpack Compose

State Management

Manage UI state with remember, mutableStateOf, and state hoisting patterns.

50m
3 problems
Topic Progress 0%

remember and mutableStateOf

Why State Matters in Compose

In Compose, the UI is a function of state. When state changes, the UI recomposes. Without state, nothing changes on screen. The core state APIs are remember and mutableStateOf.

Creating State

@Composable
fun Counter() {
    // remember preserves the value across recompositions
    // mutableStateOf creates an observable state holder
    var count by remember { mutableIntStateOf(0) }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

remember stores the value in the composition's slot table. It survives recomposition but is lost on configuration change (rotation, etc.). For that, use rememberSaveable.

remember vs rememberSaveable

// Survives recomposition, lost on process death / config change
var text by remember { mutableStateOf("") }

// Survives both recomposition and config change
var text by rememberSaveable { mutableStateOf("") }

rememberSaveable uses a Bundle under the hood, so it only works with types that can be saved to a Bundle (primitives, Strings, Serializable, Parcelable, or custom Saver objects).

State Types in Compose

API Survives Recomposition Survives Config Change Observable
mutableStateOf No No Yes
remember { mutableStateOf } Yes No Yes
rememberSaveable { mutableStateOf } Yes Yes Yes
mutableStateListOf Yes No Yes

List State

For observable lists, use mutableStateListOf:

@Composable
fun TodoList() {
    var items = remember { mutableStateListOf<TodoItem>() }

    Column {
        items.forEach { item ->
            TodoRow(item = item, onRemove = { items.remove(item) })
        }
    }
}

Delegated State

Kotlin property delegation (by) makes state read/write more natural:

// Without delegation - verbose
val countState = remember { mutableIntStateOf(0) }
Text(countState.intValue.toString())
Button(onClick = { countState.intValue++ }) { }

// With delegation - clean
var count by remember { mutableIntStateOf(0) }
Text(count.toString())
Button(onClick = { count++ }) { }

State Hoisting

What Is State Hoisting?

State hoisting is the pattern of moving state up from a child composable to its parent. The child becomes stateless: it receives state as a parameter and emits events upward. This makes composables reusable, testable, and easier to reason about.

Before Hoisting

// Stateful - has internal state, hard to reuse or test
@Composable
fun NameInput() {
    var name by remember { mutableStateOf("") }
    TextField(
        value = name,
        onValueChange = { name = it },
        label = { Text("Enter name") }
    )
}

After Hoisting

// Stateless - receives state and events from parent
@Composable
fun NameInput(
    value: String,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(
        value = value,
        onValueChange = onValueChange,
        label = { Text("Enter name") },
        modifier = modifier
    )
}

// Parent controls the state
@Composable
fun GreetingScreen() {
    var name by remember { mutableStateOf("") }
    Column(modifier = Modifier.padding(16.dp)) {
        NameInput(value = name, onValueChange = { name = it })
        if (name.isNotEmpty()) {
            Text(text = "Hello, $name!")
        }
    }
}

The Hoisting Rule

If a composable has state, ask:

  1. Is this state needed by the parent? If yes, hoist it.
  2. Is this state needed across recompositions? Use remember.
  3. Is this state needed across config changes? Use rememberSaveable.
  4. Should the state survive navigation? Use a ViewModel.

Single Source of Truth

State hoisting enforces a single source of truth. Instead of multiple composables each holding their own copy of data, one parent owns the state and distributes it down. This prevents inconsistencies.

// BAD: Multiple sources of truth
@Composable
fun FormScreen() {
    Column {
        EmailInput()     // Each holds its own state
        EmailConfirm()   // No way to compare them
    }
}

// GOOD: Single source of truth
@Composable
fun FormScreen() {
    var email by remember { mutableStateOf("") }
    Column {
        EmailInput(value = email, onValueChange = { email = it })
        EmailConfirm(value = email, onValueChange = { /* validate */ })
    }
}

Hoisting Patterns by Complexity

Simple state: remember + mutableStateOf at the parent level.

Complex state: Use a state holder class that encapsulates related state and logic.

// State holder groups related state and actions
class FormState {
    var email by mutableStateOf("")
        private set
    var password by mutableStateOf("")
        private set
    val isValid: Boolean get() = email.contains("@") && password.length >= 8

    fun updateEmail(newEmail: String) { email = newEmail }
    fun updatePassword(newPassword: String) { password = newPassword }
}

@Composable
fun rememberFormState(): FormState {
    return remember { FormState() }
}

@Composable
fun LoginScreen() {
    val state = rememberFormState()
    Column(modifier = Modifier.padding(16.dp)) {
        EmailInput(value = state.email, onValueChange = state::updateEmail)
        PasswordInput(value = state.password, onValueChange = state::updatePassword)
        Button(
            onClick = { /* submit */ },
            enabled = state.isValid
        ) {
            Text("Log In")
        }
    }
}

This keeps the composable tree clean while centralizing business logic.

Derived State and StateFlow

derivedStateOf

Sometimes you have one piece of state that derives from another. Recomputing derived values on every recomposition wastes work. derivedStateOf caches the computation and only triggers recomposition when the result actually changes.

@Composable
fun FilteredList(items: List<String>, query: String) {
    val filtered by remember(items, query) {
        derivedStateOf {
            if (query.isEmpty()) items
            else items.filter { it.contains(query, ignoreCase = true) }
        }
    }
    LazyColumn {
        items(filtered) { Text(it, modifier = Modifier.padding(16.dp)) }
    }
}

Without derivedStateOf, the filter runs on every recomposition. With it, the filter only runs when items or query changes, and the list UI only recomposes when the filtered result differs.

When to Use derivedStateOf

Use it when:

  • A derived value changes less frequently than the state it reads.
  • You want to avoid unnecessary recompositions.
  • The computation is expensive.

Do not use it when:

  • The derived value changes as often as the source (just read the state directly).
  • The computation is trivial (just a property access).

StateFlow and collectAsStateWithLifecycle

In production apps, state often lives in ViewModels as StateFlow. Compose provides collectAsStateWithLifecycle to collect flows safely:

class UserViewModel(private val repo: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState(isLoading = true)
            val user = repo.getUser(id)
            _uiState.value = UserUiState(user = user)
        }
    }
}

@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when {
        uiState.isLoading -> CircularProgressIndicator()
        uiState.user != null -> UserContent(uiState.user!!)
        else -> Text("No user found")
    }
}

collectAsStateWithLifecycle automatically stops collection when the composable leaves the composition (e.g., when the app goes to the background), preventing leaks.

ViewModel as State Holder

ViewModels are the recommended state holder for screen-level composables. They survive configuration changes, scope to a navigation destination, and integrate with Hilt for dependency injection.

@HiltViewModel
class DashboardViewModel @Inject constructor(
    private val getDashboardData: GetDashboardDataUseCase
) : ViewModel() {
    var uiState by mutableStateOf(DashboardUiState())
        private set

    init { loadData() }

    private fun loadData() {
        viewModelScope.launch {
            uiState = DashboardUiState(isLoading = true)
            val data = getDashboardData()
            uiState = DashboardUiState(data = data)
        }
    }
}

Quiz

1. What is the difference between remember and rememberSaveable?

Question 1 options

2. What is state hoisting?

Question 2 options

3. When should you use derivedStateOf instead of reading state directly?

Question 3 options

4. Why is collectAsStateWithLifecycle preferred over collectAsState?

Question 4 options

Flashcards

Question

What does remember do in Compose?

Answer

Stores a value in the composition that survives recomposition but is lost on configuration change. Used with mutableStateOf to create observable state.

Question

What is state hoisting and why is it important?

Answer

Moving state from child to parent composable. Makes children stateless, reusable, testable, and enforces single source of truth.

Question

When should you use a ViewModel for state instead of remember?

Answer

When state must survive configuration changes, is needed across navigation events, or holds complex business logic that belongs outside the UI layer.

Question

What problem does derivedStateOf solve?

Answer

It caches derived computations and only triggers recomposition when the result changes, preventing unnecessary recompositions when source state changes frequently.

Revision Notes

Key Takeaways

  • 1. remember preserves state across recompositions, rememberSaveable also survives configuration changes
  • 2. State hoisting makes composables stateless, reusable, and testable
  • 3. derivedStateOf prevents unnecessary recompositions by caching derived values
  • 4. ViewModels are the recommended state holder for screen-level composables
  • 5. collectAsStateWithLifecycle is preferred over collectAsState for lifecycle safety

Interview Tips

  • Explain state hoisting with a concrete example: before/after code
  • Know when to use remember vs rememberSaveable vs ViewModel
  • Discuss derivedStateOf vs reading state directly for performance
  • Be ready to explain how StateFlow integrates with Compose via collectAsStateWithLifecycle

Cheat Sheet

State Management Cheat Sheet

State Creation:

  • remember { mutableStateOf(value) } -- survives recomposition
  • rememberSaveable { mutableStateOf(value) } -- survives config change
  • mutableStateListOf() -- observable list

Delegation:

  • var x by remember { mutableStateOf(0) } -- clean read/write

Hoisting:

  • Stateless: state as param, events as lambdas
  • Single source of truth in parent
  • State holder class for complex state

Derived:

  • derivedStateOf { ... } -- cache computations
  • Use when result changes less than source

ViewModel Integration:

  • collectAsStateWithLifecycle() -- lifecycle-aware flow collection
  • ViewModel survives config change and navigation