Recommended Reading

29 Minutes
Jetpack Compose State Management: A Guide for Android Developers
Fix Bugs Faster! Log Collection Made Easy
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:
- How state works in Jetpack Compose, not just the what and how but the why behind this awesome function.
- 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).
Table of Contents
- First, the core definitions: Fundamentals of state in Jetpack Compose
- Getting started: Using state management APIs in Jetpack Compose
- Going from strength to strength: working with state holders
- Managing complex state
- Side effects in Jetpack Compose
- Advanced patterns
- Best practices and performance optimization
- To sum up
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:
- Better Reusability: A stateless composable can be dropped into multiple places without worrying about internal behavior.
- Simpler Testing: No internal state means fewer side effects and easier unit testing.
- Centralized State Management: It’s easier to track and control state changes when everything is managed in one place.
- 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:
- 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. - 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.
- 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.
- 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
andmutableStateOf
toViewModel
,StateFlow
, andcollectAsState
, 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
, andrememberCoroutineScope
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