State Management in Jetpack Compose: remember, mutableStateOf, and Beyond

Published: (March 1, 2026 at 07:20 PM EST)
7 min read
Source: Dev.to

Source: Dev.to

Understanding Composition and Recomposition

Before diving into state management, it’s important to understand how Compose works. When you write a composable function, it is executed as part of Compose’s composition process. As state changes, Compose recomposes—it re‑executes composable functions to update the UI.

Key challenge: values created locally in a composable function are recreated on every recomposition. This is where state‑management tools come in.

The Basics: remember and mutableStateOf

remember: Preserving Values Across Recompositions

remember is the foundation of state management in Compose. It allows you to preserve a value across recompositions:

@Composable
fun CounterExample() {
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Clicked $count times")
    }
}

In this example, count is retained across recompositions. When the button is clicked, the state updates, triggering a recomposition that reflects the new value.

mutableStateOf: Creating Observable State

mutableStateOf creates a state object that Compose observes. When the state changes, any composables that read that state are recomposed:

@Composable
fun LoginForm() {
    val emailState = remember { mutableStateOf("") }
    val passwordState = remember { mutableStateOf("") }

    Column {
        TextField(
            value = emailState.value,
            onValueChange = { emailState.value = it },
            label = { Text("Email") }
        )
        TextField(
            value = passwordState.value,
            onValueChange = { passwordState.value = it },
            label = { Text("Password") }
        )
    }
}

Using the delegation syntax (var … by) is more concise and idiomatic:

@Composable
fun LoginForm() {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    Column {
        TextField(
            value = email,
            onValueChange = { email = it },
            label = { Text("Email") }
        )
        TextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("Password") }
        )
    }
}

rememberSaveable: Preserving State Through Configuration Changes

remember preserves state across recompositions, but it does not survive configuration changes (e.g., screen rotation). For persistence across such changes, use rememberSaveable:

@Composable
fun PersistentCounterExample() {
    var count by rememberSaveable { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Clicked $count times (survives rotation)")
    }
}

rememberSaveable leverages the Bundle mechanism to save and restore state, similar to traditional Android development.

derivedStateOf: Computing Values from State

Sometimes you need to compute a value based on state changes, but you don’t want recomposition to happen for every change. derivedStateOf creates a derived state that only notifies observers when its value actually changes:

@Composable
fun TextSearchExample() {
    var searchQuery by remember { mutableStateOf("") }

    // This expensive computation only runs when searchQuery actually changes
    val searchResults by remember(searchQuery) {
        derivedStateOf { performExpensiveSearch(searchQuery) }
    }

    Column {
        TextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("Search") }
        )
        LazyColumn {
            items(searchResults.size) { index ->
                Text(searchResults[index])
            }
        }
    }
}

Even if searchQuery is updated rapidly, the expensive search operation runs only when the derived value would actually change.

State Hoisting: Lifting State Up

State hoisting is a design pattern where you move state to a common parent composable. This makes the state shareable between multiple child composables and easier to test:

@Composable
fun ParentComponent() {
    var sharedState by remember { mutableStateOf("") }

    Column {
        ChildComponentA(
            state = sharedState,
            onStateChange = { sharedState = it }
        )
        ChildComponentB(state = sharedState)
    }
}

@Composable
fun ChildComponentA(
    state: String,
    onStateChange: (String) -> Unit
) {
    TextField(
        value = state,
        onValueChange = onStateChange
    )
}

@Composable
fun ChildComponentB(state: String) {
    Text("Shared state: $state")
}

State hoisting makes your composables more reusable and easier to test because their behavior depends only on parameters, not internal state.

ViewModel vs. Local State: When to Use Each

  • Local state (remember, mutableStateOf) – Ideal for UI‑only state that does not need to survive process death or be shared across multiple screens.
  • ViewModel‑backed state – Use when the state must survive configuration changes, be shared across composables in different parts of the UI, or be persisted beyond the UI lifecycle (e.g., data fetched from a repository).
class CounterViewModel : ViewModel() {
    private val _count = mutableStateOf(0)
    val count: State = _count

    fun increment() { _count.value++ }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count
    Button(onClick = { viewModel.increment() }) {
        Text("Clicked $count times")
    }
}

In this example, the count survives configuration changes because it lives in a ViewModel.

Summary

  • remember + mutableStateOf → UI‑local, recomposition‑aware state.
  • rememberSaveable → UI‑local state that survives configuration changes.
  • derivedStateOf → Efficient derived values that avoid unnecessary recompositions.
  • State hoisting → Improves reusability and testability.
  • ViewModel → Handles shared, long‑lived, or process‑surviving state.

Understanding and applying these tools will help you build robust, maintainable, and performant Compose applications. Happy composing!

Use remember for UI‑related State That Doesn’t Need to Survive Process Death

Typical use‑cases

  • Toggle visibility of UI elements
  • Text field input while editing
  • Scroll position

Temporary UI state

@Composable
fun ToggleVisibility() {
    var isVisible by remember { mutableStateOf(true) }

    Button(onClick = { isVisible = !isVisible }) {
        Text(if (isVisible) "Hide" else "Show")
    }

    if (isVisible) {
        Text("Content is visible")
    }
}

ViewModel for Business Logic and Data That Should Survive Process Death

Typical use‑cases

  • User data from a database or API
  • Application‑wide state
  • Business logic

State that persists across the app lifecycle

class UserViewModel : ViewModel() {
    private val _userState = MutableStateFlow<User?>(null)
    val userState: StateFlow<User?> = _userState.asStateFlow()

    init {
        loadUser()
    }

    private fun loadUser() {
        viewModelScope.launch {
            _userState.value = userRepository.getUser()
        }
    }
}

@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
    val user by viewModel.userState.collectAsState()

    user?.let {
        Text("Welcome, ${it.name}")
    }
}

Combining ViewModel with Local State

The most robust pattern mixes both approaches:

class ProductViewModel : ViewModel() {
    private val _products = MutableStateFlow<List<Product>>(emptyList())
    val products: StateFlow<List<Product>> = _products.asStateFlow()

    init {
        loadProducts()
    }

    private fun loadProducts() {
        viewModelScope.launch {
            _products.value = productRepository.getProducts()
        }
    }
}

@Composable
fun ProductListScreen(viewModel: ProductViewModel = hiltViewModel()) {
    val products by viewModel.products.collectAsState()
    var selectedProductId by remember { mutableStateOf<String?>(null) }

    Row {
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(products) { product ->
                ProductItem(
                    product = product,
                    isSelected = selectedProductId == product.id,
                    onSelect = { selectedProductId = product.id }
                )
            }
        }

        selectedProductId?.let { id ->
            ProductDetails(
                product = products.find { it.id == id },
                modifier = Modifier.weight(1f)
            )
        }
    }
}

Best Practices for State Management

  • Hoist state as high as needed – Move state to the lowest common parent of composables that need it.
  • Keep state close to where it’s used – Don’t hoist higher than necessary; this preserves reusability.
  • Use ViewModel for persistent state – Data that must survive process death belongs in a ViewModel.
  • Avoid mutable shared state – Prefer immutable data structures and a unidirectional data flow.
  • Test composables with state hoisting – Hoisted state makes composables easier to test because you can inject test values.
  • Use rememberSaveable for UI state – When UI state must survive configuration changes, wrap it with rememberSaveable.

Conclusion

State management in Jetpack Compose becomes straightforward once you understand the core tools:

  • remember / mutableStateOf → local, temporary UI state
  • rememberSaveable → UI state that survives configuration changes
  • ViewModel → persistent business logic and data

Choose the right tool for each situation: use local state for transient UI concerns, a ViewModel for data that must outlive the UI, and hoist state to share it efficiently between composables.

All 8 templates demonstrate proper state management.
https://myougatheax.gumroad.com

0 views
Back to Blog

Related posts

Read more »