Skip to content

Recommended Reading

Jetpack Compose State Management: A Guide for Android Developers

29 Minutes

Jetpack Compose State Management: A Guide for Android Developers

Fix Bugs Faster! Log Collection Made Easy

Get started

There have been huge changes in Android development over the years, but none has been as significant as Jetpack Compose state management.

This isn’t just another toolkit update. It’s a flight to freedom, a rethinking of how we build user interfaces from the ground up. Jetpack Compose gives us a new, declarative-style UI development, which means cleaner code and introduces a powerful state management system at the heart of the entire framework.

However, this shift to a declarative model requires developers to adopt a new mental model. Instead of instructing the UI’s behavior, we’re describing what the UI should look like based on its current state. This will be a new concept for many developers, so in this article we´re going to take a deep dive, looking at:

  1. How state works in Jetpack Compose, not just the what and how but the why behind this awesome function.
  2. How to work with the principles of Jetpack Compose’s state-driven architecture to develop robust, efficient and maintainable applications.

The article will be useful for developers looking to take their first steps with Jetpack Compose and the principles of effective state management (just a quick note, however: this is specifically about Android Jetpack Compose techniques. If you’re looking for info on compose multiplatform, stay tuned for a dedicated blog post in due course).

First, the core definitions: Fundamentals of state in Jetpack Compose

What is state and how does it relate to Jetpack Compose UI?

In Jetpack Compose, state refers to any piece of data that can change over time and has a direct impact on what your UI looks like. If that value changes — whether it’s a simple Int, a String, or a more complex state object like a list or custom data class — Jetpack Compose responds by updating the UI accordingly. That’s the core idea: the UI is a direct reflection of the current state.

The Recomposition process

Recomposition is the process Jetpack Compose uses to update the UI whenever state changes. When a state value is modified, Compose doesn’t rebuild the whole screen blindly, it marks only the affected composables as requiring an update.

Then, during recomposition, it simply re-executes those composable functions to generate an updated UI description.

Finally, it efficiently applies only the necessary changes to the actual UI. No more unnecessary redraws. No more manually juggling view updates. Just responsive and clean rendering.

State hoisting

State hoisting is a key pattern in Jetpack Compose that helps make composables more modular and reusable, which is great when we’re dealing with multiple composables at once.

In simple terms, hoisting means moving state out of a composable function and into the calling function, turning your composable into a stateless UI building block. With state hoisting, we get:

  1. Better Reusability: A stateless composable can be dropped into multiple places without worrying about internal behavior.
  2. Simpler Testing: No internal state means fewer side effects and easier unit testing.
  3. Centralized State Management: It’s easier to track and control state changes when everything is managed in one place.
  4. Unidirectional Data Flow: Data flows down, events flow up, making app and UI logic more predictable (and debuggable).
// Examle code Before hoisting (this is stateful)
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    CounterDisplay(count = count, onIncrement = { count++ })
}

// Example code After hoisting (this is stateless)
@Composable
fun Counter(count: Int, onCountChange: (Int) -> Unit) {
    CounterDisplay(count = count, onIncrement = { onCountChange(count + 1) })
}

@Composable
fun CounterDisplay(count: Int, onIncrement: () -> Unit) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium)
        Button(onClick = onIncrement) {
            Text("Increment")
        }
    }
}

Getting started: Using state management APIs in Jetpack Compose

Jetpack Compose offers a handful of purpose-built APIs to help you manage state effectively. Each one is optimized for different scenarios, from a simple UI state to more complex app-wide data flows.

remember and mutableStateOf

The remember function is used to store values across recompositions. It lives within the composition and retains its value as long as the composable that owns it remains part of the UI.

Pair that with mutableStateOf, and you’ve got a reactive state holder. When the value inside mutableStateOf changes, Compose automatically knows that something needs to be redrawn, so it triggers a recomposition of any composables that are reading that state.

//Quick example of remember and mutableStateOf
@Composable
fun ExpandableCard() {
    var expanded by remember { mutableStateOf(false) }

    Card(
        modifier = Modifier
            .clickable { expanded = !expanded }
            .padding(16.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Text("Click for expand/collapse", fontWeight = FontWeight.Bold)

            AnimatedVisibility(visible = expanded) {
                Text(
                    text = "Expanded content that appears when the card clicked.",
                    modifier = Modifier.padding(top = 8.dp)
                )
            }
        }
    }
}

rememberSaveable

While remember preserves state during recomposition, it doesn’t survive a configuration change (like screen rotation). rememberSaveable extends this functionality by saving state across each configuration change and process death.

remember does a great job holding onto state during recomposition, meaning that if your composable gets redrawn, the value sticks around. But it has one blind spot, and tthat is configuration changes. You know, like when the user rotates the screen, switches to dark mode, or some other system event kicks in. When that happens, remember forgets everything.

That’s where rememberSaveable comes in. This function works just like remember, but it automatically saves your state across configuration changes.

// Quick example of rememberSaveable
@Composable
fun UserInputForm() {
    var name by rememberSaveable { mutableStateOf("") }
    var email by rememberSaveable { mutableStateOf("") }

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(8.dp))

        OutlinedTextField(
            value = email,
            onValueChange = { email = it },
            label = { Text("Email") },
            modifier = Modifier.fillMaxWidth()
        )
    }
}

derivedStateOf

This API lets you define a value that’s automatically recalculated only when its dependencies change. So instead of recalculating on every recomposition, Compose will cache the result and only provide state updates when it actually needs to.

//Example of derivedStateOf showing filtered list based on search
@Composable
fun FilteredList(items: List<String>) {
    var searchQuery by remember { mutableStateOf("") }

    // Derived state, filtered list result based on the search query
    val filteredItems by remember(searchQuery, items) {
        derivedStateOf {
            items.filter { it.contains(searchQuery, ignoreCase = true) }
        }
    }

    Column {
        OutlinedTextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("Search") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
        )

        LazyColumn {
            items(filteredItems) { item ->
                Text(
                    text = item,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp)
                )
            }
        }
    }
}

produceState

Jetpack Compose is awesome for reactive UI. But what about data that lives outside the Compose world, like Flows, LiveData, or async data from a network request?

produceState is designed to turn non-Compose sources into a Compose state. Inside it, you can launch coroutines, collect flows, or fetch data from any suspending function and emit those results to your UI in a safe, Compose-friendly way.

//Example of produceState used in network request loading an image
@Composable
fun NetworkImage(url: String) {
    val imageState = produceState<Result<ImageBitmap>>(initialValue = Result.Loading) {
        value = try {
            val bitmap = loadNetworkImage(url)
            Result.Success(bitmap)
        } catch (e: Exception) {
            Result.Error(e)
        }
    }

    when (val state = imageState.value) {
        is Result.Loading -> CircularProgressIndicator()
        is Result.Success -> Image(
            bitmap = state.data,
            contentDescription = null,
            modifier = Modifier.size(200.dp)
        )
        is Result.Error -> Text("Error loading image: ${state.exception.message}")
    }
}

collectAsState

collectAsState is the go-to API for observing Flow or StateFlow in a composable. It collects the stream of data and exposes it as a Compose State, so the UI updates automatically as new values come in.

//collectAsState example
@Composable
fun UserProfile(viewModel: UserViewModel) {
    val userState by viewModel.userFlow.collectAsState(initial = null)

    userState?.let { user ->
        Column(modifier = Modifier.padding(16.dp)) {
            Text(text = "Name: ${user.name}", style = MaterialTheme.typography.headlineSmall)
            Text(text = "Email: ${user.email}")
            Text(text = "Joined: ${user.joinDate}")
        }
    } ?: run {
        CircularProgressIndicator()
    }
}

//And the corresponding ViewModel:
class UserViewModel : ViewModel() {
    private val _userFlow = MutableStateFlow<User?>(null)
    val userFlow: StateFlow<User?> = _userFlow

    init {
        viewModelScope.launch {
            _userFlow.value = fetchUser() // Simulate network fetch
        }
    }
}

Going from strength to strength: working with state holders

As your app grows, so does your state. What starts out as a few toggles and text fields can quickly balloon into multiple screens, asynchronous data, user inputs and edge cases. Which, of course, means multiple composable functions.

Here’s where state holders kick in. They help you separate concerns, keep your UI clean, and maintain a well-structured, testable codebase.

ViewModel integration

In most Compose apps, the ViewModel is your first stop for centralized state management. It’s responsible for handling business logic and holding UI-related data, especially when you need state to survive configuration changes or tie into app-wide logic.

//Example of ViewModel Integration
class TodoViewModel : ViewModel() {
    private val _todoItems = MutableStateFlow<List<TodoItem>>(emptyList())
    val todoItems: StateFlow<List<TodoItem>> = _todoItems

    private val _newTodoText = MutableStateFlow("")
    val newTodoText: StateFlow<String> = _newTodoText

    fun updateNewTodoText(text: String) {
        _newTodoText.value = text
    }

    fun addTodo() {
        if (_newTodoText.value.isNotBlank()) {
            val newItem = TodoItem(
                id = UUID.randomUUID().toString(),
                text = _newTodoText.value,
                isCompleted = false
            )
            _todoItems.value = _todoItems.value + newItem
            _newTodoText.value = ""
        }
    }

    fun toggleTodoCompletion(id: String) {
        _todoItems.value = _todoItems.value.map { item ->
            if (item.id == id) item.copy(isCompleted = !item.isCompleted) else item
        }
    }
}

//observe that state using collectAsState():
@Composable
fun TodoScreen(viewModel: TodoViewModel = viewModel()) {
    val todoItems by viewModel.todoItems.collectAsState()
    val newTodoText by viewModel.newTodoText.collectAsState()

    // Render UI here using state
}

Dedicated state holder classes

For features that don’t necessarily need a full ViewModel, you can use custom state holder classes. This is especially useful for components like expandable cards, tab states, or feature-specific toggles.

//Example of Dedicated State Holder Classes
class ExpandableCardState( initialExpanded: Boolean = false, private val onExpandChanged: ((Boolean) -> Unit)? = null
) {
    var expanded by mutableStateOf(initialExpanded)
        private set

    fun toggleExpanded() {
        expanded = !expanded
        onExpandChanged?.invoke(expanded)
    }
}

@Composable
fun rememberExpandableCardState(initialExpanded: Boolean = false, onExpandChanged: ((Boolean) -> Unit)? = null): ExpandableCardState {
    return remember { ExpandableCardState(initialExpanded, onExpandChanged) }
}

//Use it in a stateless component:
@Composable
fun ExpandableCard(
    title: String,
    content: String,
    state: ExpandableCardState = rememberExpandableCardState()
) {
    Card(
        modifier = Modifier
            .clickable { state.toggleExpanded() }
            .padding(16.dp)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.SpaceBetween
            ) {
                Text(title, fontWeight = FontWeight.Bold)
                Icon(
                    imageVector = if (state.expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
                    contentDescription = null
                )
            }
            AnimatedVisibility(state.expanded) {
                Text(content, modifier = Modifier.padding(top = 8.dp))
            }
        }
    }
}

Managing complex state

State for lists and collections

State in Jetpack Compose isn’t just about booleans and text fields. The trick with lists is ensuring efficient updates and preserving identity (especially with animations or state retention).

//Example of State for lists and collections
@Composable
fun ShoppingList() {
    val groceryItems = remember {
        mutableStateListOf(
            GroceryItem("Apples", 5),
            GroceryItem("Bananas", 3),
            GroceryItem("Milk", 1)
        )
    }

    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Shopping List",
            style = MaterialTheme.typography.headlineMedium,
            modifier = Modifier.padding(bottom = 16.dp)
        )

        groceryItems.forEachIndexed { index, item ->
            Row(
                verticalAlignment = Alignment.CenterVertically,
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(vertical = 8.dp)
            ) {
                Text(
                    text = "${item.name} (${item.quantity})",
                    modifier = Modifier.weight(1f)
                )

                IconButton(onClick = {
                    // Update quantity
                    groceryItems[index] = item.copy(quantity = item.quantity + 1)
                }) {
                    Icon(Icons.Default.Add, contentDescription = "Add")
                }

                IconButton(onClick = {
                    // Decrease quantity or remove if zero
                    if (item.quantity > 1) {
                        groceryItems[index] = item.copy(quantity = item.quantity - 1)
                    } else {
                        groceryItems.removeAt(index)
                    }
                }) {
                    Icon(Icons.Default.Remove, contentDescription = "Remove")
                }
            }
        }

        // Add new item button
        Button(
            onClick = {
                groceryItems.add(GroceryItem("New Item", 1))
            },
            modifier = Modifier
                .align(Alignment.End)
                .padding(top = 16.dp)
        ) {
            Text("Add Item")
        }
    }
}

data class GroceryItem(val name: String, val quantity: Int)

Immutable vs Mutable state models

While mutableStateOf is quick and convenient, immutable state models offer better traceability, especially for debugging and testing.

//Example of Immutable vs Mutable State Models
@Composable
fun UserProfileEditor() {
    // Immutable data class representing state
    data class UserProfileState(
        val name: String = "",
        val email: String = "",
        val bio: String = "",
        val notifications: Boolean = false
    )

    // State holder
    var userProfile by remember { mutableStateOf(UserProfileState()) }

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = userProfile.name,
            onValueChange = { userProfile = userProfile.copy(name = it) },
            label = { Text("Name") },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(8.dp))

        OutlinedTextField(
            value = userProfile.email,
            onValueChange = { userProfile = userProfile.copy(email = it) },
            label = { Text("Email") },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(8.dp))

        OutlinedTextField(
            value = userProfile.bio,
            onValueChange = { userProfile = userProfile.copy(bio = it) },
            label = { Text("Bio") },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(8.dp))

        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Enable notifications", modifier = Modifier.weight(1f))
            Switch(
                checked = userProfile.notifications,
                onCheckedChange = { userProfile = userProfile.copy(notifications = it) }
            )
        }

        // Display current state for demonstration
        Spacer(modifier = Modifier.height(16.dp))
        Text("Current State:", fontWeight = FontWeight.Bold)
        Text("Name: ${userProfile.name}")
        Text("Email: ${userProfile.email}")
        Text("Bio: ${userProfile.bio}")
        Text("Notifications: ${userProfile.notifications}")
    }
}

Side effects in Jetpack Compose

Sometimes, your composables need to do more than render UI. Side effects enter the picture, like launching coroutines, listening to network changes, or logging analytics. Jetpack Compose provides APIs to manage these cleanly.

Here’s a quick rundown:

LaunchedEffect

Runs a coroutine when the keys change, or when the composable enters composition:

//Example of LaunchedEffect
@Composable
fun AutoSavingForm(viewModel: FormViewModel) {
    var text by remember { mutableStateOf("") }

    LaunchedEffect(text) {
        // Debounce to avoid excessive save operations
        delay(500)
        viewModel.saveText(text)
    }

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = text,
            onValueChange = { text = it },
            label = { Text("Enter text (auto-saves after typing stops)") },
            modifier = Modifier.fillMaxWidth()
        )
    }
}

DisposableEffect

Is used for setup/teardown logic:

//Example DisposableEffect
@Composable
fun NetworkConnectivityMonitor(onConnectionChanged: (Boolean) -> Unit) {
    DisposableEffect(Unit) {
        val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

        val networkCallback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                onConnectionChanged(true)
            }

            override fun onLost(network: Network) {
                onConnectionChanged(false)
            }
        }

        connectivityManager.registerDefaultNetworkCallback(networkCallback)

        // Cleanup when leaving composition
        onDispose {
            connectivityManager.unregisterNetworkCallback(networkCallback)
        }
    }
}

SideEffect

SideEffect Runs code after every successful recomposition:

//Example of SideEffect
@Composable
fun AnalyticsScreen(screenName: String) {
    // This runs on every successful recomposition
    SideEffect {
        FirebaseAnalytics.logEvent("screen_view", bundleOf("screen_name" to screenName))
    }

    // Screen content
    Column(modifier = Modifier.padding(16.dp)) {
        Text("This is the $screenName screen")
    }
}

rememberCoroutineScope

rememberCoroutineScope Gives you a scoped coroutine context tied to the composable lifecycle:

//Example of rememberCoroutineScope
@Composable
fun LoadingButton(onButtonClick: suspend () -> Unit) {
    val coroutineScope = rememberCoroutineScope()
    var isLoading by remember { mutableStateOf(false) }

    Button(
        onClick = {
            coroutineScope.launch {
                isLoading = true
                try {
                    onButtonClick()
                } finally {
                    isLoading = false
                }
            }
        },
        enabled = !isLoading,
        modifier = Modifier.fillMaxWidth()
    ) {
        if (isLoading) {
            CircularProgressIndicator(
                modifier = Modifier.size(20.dp),
                color = MaterialTheme.colorScheme.onPrimary
            )
        } else {
            Text("Click Me")
        }
    }
}

Advanced patterns

Now we’re getting really granular! Let’s look at the patterns that allow us to handle complex edge cases.

  • Composition locals: Sometimes you need to pass data down the tree without passing it explicitly through every function. Composition locals provide a way to do this.
  • State restoration across process death: For more complex objects, rememberSaveable can be extended with custom savers:
//rememberSaveable example
val UserSaver = Saver<User, List<String>>(
    save = { listOf(it.name, it.email, it.joinDate) },
    restore = { User(it[0], it[1], it[2]) }
)

var user by rememberSaveable(stateSaver = UserSaver) {
    mutableStateOf(User("", "", ""))
}

Best practices and performance optimization

Minimizing recompositions

Here are a few tried-and-true strategies to keep your UI lean and mean, and put you well on the way to mastering state management:

  1. Prefer immutable data structures: Immutable data makes it easy for Jetpack Compose to determine whether something has changed. Since Jetpack Compose uses structural equality (==) to decide whether it needs to recompose, having stable, immutable data structures gives it an accurate signal.
  2. Hoist state only when necessary: We should keep state local to the component that actually uses it unless it needs to be shared or persisted.
  3. Use state holders for complex or related state: When we’re juggling multiple state values that logically belong together, it’s best to encapsulate them into a dedicated state holder class. It keeps our code cleaner, our recompositions more scoped, and our mental model easier to manage.
  4. Use remember with keys to control reuse: remember caches values across recompositions, but it only does so if the keys stay the same. If we don’t give Jetpack Compose the right keys, it might reuse values when it shouldn’t or recalculate things unnecessarily.

To sum up

Everything we see on the screen, every color, every toggle, every animation is driven by how our data flows and evolves over time. And when we truly understand the principles of managing state effectively, Jetpack Compose becomes not just easier to use, but a joy to build with.

In this article, we discussed the fundamentals of state management including remember and mutableStateOf, as well as covering the advanced usage and practical examples of using state management in developing Android apps. Here’s a summary of what we’ve discussed:

  • In Compose, you describe your UI based on current state. Managing it cleanly ensures your UI stays predictable, testable, and bug-resistant.
  • We learned the use of remember and mutableStateOf to ViewModel, StateFlow, and collectAsState, Compose gives us these flexible options.
  • We learned to keep the UI components focused on display, and push logic and state into ViewModels or dedicated state holders.
  • We should avoid the unnecessary recompositions, using immutable data, and scope the state properly. We need to keep our composables as granular as possible.
  • Compose offers structured tools like LaunchedEffect, SideEffect, and rememberCoroutineScope to help us integrate background tasks.

But if there’s one thing we want you to remember above all others, it’s this: State management in Compose isn’t just about tools and APIs, it’s about mindset. It’s about thinking declaratively, designing flows instead of procedures, and building UIs that are reactive by default.

As our applications become increasingly complex with more features and functionalities, state management becomes an ever-more helpful tool in developing Android applications. Have fun exploring this technology, and if you need any more guidance from our side, get in touch via our Contact Us page.

Happy Coding!

Expect The Unexpected!

Debug Faster With Bugfender

Start for Free
blog author

Sachin Siwal

Sachin is an accomplished iOS developer and seasoned technical manager, combining 12 years of mobile app expertise with a knack for leading and delivering innovative solutions. You can contact him on Linkedin

Join thousands of developers
and start fixing bugs faster than ever.