State management is the backbone of any modern Android app. If state is messy, your UI becomes unpredictable, buggy, and hard to maintain. Jetpack Compose was designed to solve many of these problems, but only if you understand how State Management in Jetpack Compose actually works.
In this post, we will break down modern state management in Jetpack Compose using simple examples. We will cover UI state, Kotlin Flows, side effects, and how they all fit together in a clean, scalable way.
What Does State Mean in Jetpack Compose?
In simple terms, state is data that the UI depends on.
If the state changes, the UI should update automatically.
Examples of state:
- A loading flag
- A list of items
- User input text
- An error message
Jetpack Compose is state-driven. This means you do not tell the UI how to update. You just update the state, and Compose handles the rest.
This is the foundation of State Management in Jetpack Compose.
UI State vs Business Logic
A common beginner mistake is mixing UI state with business logic. Modern Compose apps separate these responsibilities.
- UI state describes what the screen looks like
- Business logic decides how data changes
The ViewModel is the bridge between them.
Designing UI State the Right Way
A good UI state is:
- Immutable
- Easy to read
- Represents the entire screen
Let’s start with a simple UI state class.
data class UserUiState(
val isLoading: Boolean = false,
val userName: String = "",
val errorMessage: String? = null
)Why this works well
- It represents the full UI in one place
- It avoids multiple scattered states
- It makes the UI predictable
This pattern is widely recommended for State Management in Jetpack Compose because it scales well as your app grows.
Using ViewModel for State Management
The ViewModel holds the UI state and exposes it to the UI.
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UserUiState())
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser() {
_uiState.value = _uiState.value.copy(isLoading = true)
viewModelScope.launch {
delay(2000)
_uiState.value = UserUiState(
isLoading = false,
userName = "Amol"
)
}
}
}MutableStateFlowholds mutable state inside the ViewModelStateFlowis exposed as read-only to the UI (asStateFlow()exposes a read-only version to the UI)copy()updates only the fields that change
This approach keeps state changes controlled and safe.
Why Kotlin Flow Is Preferred in Modern Compose
Kotlin Flow is a cold asynchronous data stream. In Compose, it works beautifully with recomposition.
Benefits:
- Lifecycle-aware (collectAsStateWithLifecycle())
- Handles async data naturally
- Works perfectly with ViewModel
This is why Flow is central to State Management in Jetpack Compose.
Collecting State in Composables
Now let’s connect the ViewModel to the UI.
@Composable
fun UserScreen(viewModel: UserViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when {
uiState.isLoading -> {
CircularProgressIndicator()
}
uiState.errorMessage != null -> {
Text(text = uiState.errorMessage)
}
else -> {
Text(text = "Hello, ${uiState.userName}")
}
}
}What is happening here
collectAsStateWithLifecycle()converts Flow into Compose state- Compose automatically recomposes when state changes
- UI stays in sync with data
This is declarative UI in action.
Understanding Side Effects in Jetpack Compose
Side effects are operations that happen outside the scope of a composable function. Think network calls, database writes, or analytics events. Compose provides several side effect handlers, each with specific use cases.
LaunchedEffect: For Coroutine-Based Side Effects
Use LaunchedEffect when you need to run suspend functions:
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
var searchQuery by remember { mutableStateOf("") }
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
LaunchedEffect(searchQuery) {
// Debounce search queries
delay(300)
if (searchQuery.isNotEmpty()) {
viewModel.search(searchQuery)
}
}
Column {
SearchBar(
query = searchQuery,
onQueryChange = { searchQuery = it }
)
SearchResultsList(results = searchResults)
}
}Why this works:
LaunchedEffect(searchQuery)cancels and restarts whensearchQuerychanges- The
delay(300)creates a debounce effect—search only happens if the user stops typing for 300ms - This prevents excessive network calls while typing
DisposableEffect: Cleanup When You Leave
When you need to clean up resources, DisposableEffect is your friend:
@Composable
fun LocationTracker() {
val context = LocalContext.current
DisposableEffect(Unit) {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
as LocationManager
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
// Handle location update
}
// Other required methods...
}
// Request location updates
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
1000L,
10f,
listener
)
// Cleanup when composable leaves composition
onDispose {
locationManager.removeUpdates(listener)
}
}
}Here,
DisposableEffect(Unit)runs once when the composable enters composition- The code inside runs to set up the location listener
onDisposeruns when the composable leaves—perfect for cleanup- This prevents memory leaks from lingering listeners
SideEffect: For Non-Suspend Operations
Use SideEffect for things that should happen after every successful recomposition:
@Composable
fun AnalyticsScreen(screenName: String) {
val analytics = remember { Firebase.analytics }
SideEffect {
// This runs after every successful composition
analytics.logEvent("screen_view") {
param("screen_name", screenName)
}
}
// Your UI content here
}The difference:
SideEffectruns after every recomposition that completes successfully- It’s for operations that don’t involve suspend functions
- Great for logging, analytics, or updating non-Compose code
Advanced State Management Patterns
Now that we’ve covered the basics, let’s explore patterns that’ll level up your state management in Jetpack Compose game.
The Single Source of Truth Pattern
Always maintain one source of truth for your state:
class ShoppingCartViewModel : ViewModel() {
private val _cartState = MutableStateFlow(CartState())
val cartState = _cartState.asStateFlow()
fun addItem(item: Product) {
_cartState.update { currentState ->
val existingItem = currentState.items.find { it.product.id == item.id }
if (existingItem != null) {
// Increase quantity
currentState.copy(
items = currentState.items.map { cartItem ->
if (cartItem.product.id == item.id) {
cartItem.copy(quantity = cartItem.quantity + 1)
} else {
cartItem
}
}
)
} else {
// Add new item
currentState.copy(
items = currentState.items + CartItem(item, 1)
)
}
}
}
fun removeItem(productId: String) {
_cartState.update { currentState ->
currentState.copy(
items = currentState.items.filter { it.product.id != productId }
)
}
}
}
data class CartState(
val items: List<CartItem> = emptyList()
) {
val totalPrice: Double
get() = items.sumOf { it.product.price * it.quantity }
val itemCount: Int
get() = items.sumOf { it.quantity }
}
data class CartItem(
val product: Product,
val quantity: Int
)Why this pattern rocks:
- All cart logic lives in one place
- Derived values like
totalPriceare computed properties - State updates are atomic and predictable
- The UI just reacts to state changes
Combining Multiple Flows
Often you need to combine data from multiple sources:
class DashboardViewModel(
private val userRepository: UserRepository,
private val notificationRepository: NotificationRepository
) : ViewModel() {
val dashboardState: StateFlow<DashboardUiState> = combine(
userRepository.currentUser,
notificationRepository.unreadCount
) { user, unreadCount ->
DashboardUiState(
userName = user?.name ?: "Guest",
unreadNotifications = unreadCount,
isLoggedIn = user != null
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = DashboardUiState()
)
}
data class DashboardUiState(
val userName: String = "",
val unreadNotifications: Int = 0,
val isLoggedIn: Boolean = false
)Here,
combinemerges multiple flows into one- Whenever either source flow emits, the lambda recalculates the state
stateInconverts the regular flow to StateFlowWhileSubscribed(5000)keeps the flow active for 5 seconds after the last subscriber leaves- This is efficient state management in Jetpack Compose for complex scenarios
Handling Loading States Elegantly
Here’s a pattern I use for handling async operations:
sealed class UiState<out T> {
object Idle : UiState<Nothing>()
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
class ProductViewModel : ViewModel() {
private val _productState = MutableStateFlow<UiState<Product>>(UiState.Idle)
val productState = _productState.asStateFlow()
fun loadProduct(productId: String) {
viewModelScope.launch {
_productState.value = UiState.Loading
try {
val product = productRepository.getProduct(productId)
_productState.value = UiState.Success(product)
} catch (e: Exception) {
_productState.value = UiState.Error(
e.message ?: "Unknown error occurred"
)
}
}
}
}And here’s how you’d consume it:
@Composable
fun ProductScreen(
productId: String,
viewModel: ProductViewModel = viewModel()
) {
val productState by viewModel.productState.collectAsStateWithLifecycle()
LaunchedEffect(productId) {
viewModel.loadProduct(productId)
}
Box(modifier = Modifier.fillMaxSize()) {
when (val state = productState) {
is UiState.Idle -> {
// Show nothing or a placeholder
}
is UiState.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
is UiState.Success -> {
ProductDetails(product = state.data)
}
is UiState.Error -> {
ErrorView(
message = state.message,
onRetry = { viewModel.loadProduct(productId) }
)
}
}
}
}Why sealed classes are perfect here:
- Exhaustive when expressions — the compiler ensures you handle all cases
- Type-safe data access — 
state.dataonly exists in Success - Clear state representation
- Easy to test each state
State Hoisting: Keeping Composables Reusable
State hoisting is a pattern where you move state up to make composables stateless and reusable. This is crucial for good state management in Jetpack Compose.
Before State Hoisting (Don’t Do This)
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search...") }
)
}This looks simple, but the state is trapped inside. You can’t access or control it from outside.
After State Hoisting (Much Better)
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text("Search...") },
modifier = modifier
)
}
// Usage
@Composable
fun SearchScreen() {
var searchQuery by remember { mutableStateOf("") }
Column {
SearchBar(
query = searchQuery,
onQueryChange = { searchQuery = it }
)
// Now you can use searchQuery for other things!
if (searchQuery.isNotEmpty()) {
Text("Searching for: $searchQuery")
}
}
}The benefits:
SearchBaris now stateless and testable- You can preview it easily with different values
- State is controlled from the parent
- Reusable across different screens
Remember: Choose the Right Tool
Compose provides different remember variants for different scenarios:
remember vs rememberSaveable
@Composable
fun FormScreen() {
// Lost on configuration change (screen rotation)
var tempData by remember { mutableStateOf("") }
// Survives configuration changes
var importantData by rememberSaveable { mutableStateOf("") }
// For complex objects, use a custom Saver
var complexData by rememberSaveable(stateSaver = ComplexDataSaver) {
mutableStateOf(ComplexData())
}
}When to use what:
- Use
rememberfor temporary UI state that can be regenerated - Use
rememberSaveablefor user input or important state - Both are cleared when the composable leaves composition permanently
rememberCoroutineScope for Manual Control
@Composable
fun AnimatedButton() {
val scope = rememberCoroutineScope()
val scale = remember { Animatable(1f) }
Button(
onClick = {
scope.launch {
scale.animateTo(1.2f)
scale.animateTo(1f)
}
}
) {
Text(
text = "Press Me",
modifier = Modifier.scale(scale.value)
)
}
}rememberCoroutineScope()gives you a scope tied to the composable’s lifecycle- Perfect for launching coroutines from event handlers
- Automatically cancelled when the composable leaves
Derived State: Computing Values Efficiently
Sometimes you need to compute values from existing state. Use derivedStateOf to optimize:
@Composable
fun FilteredList(items: List<String>) {
var searchQuery by remember { mutableStateOf("") }
// Only recalculates when items or searchQuery actually change
val filteredItems by remember(items, searchQuery) {
derivedStateOf {
if (searchQuery.isEmpty()) {
items
} else {
items.filter { it.contains(searchQuery, ignoreCase = true) }
}
}
}
Column {
SearchBar(
query = searchQuery,
onQueryChange = { searchQuery = it }
)
LazyColumn {
items(filteredItems) { item ->
Text(item)
}
}
}
}The magic here:
- Without
derivedStateOf, filtering would happen on every recomposition - With it, filtering only happens when dependencies change
- This is essential for expensive computations
Testing Your State Management
Good state management in Jetpack Compose means testable code. Here’s how:
class ShoppingCartViewModelTest {
@Test
fun `adding item increases cart count`() = runTest {
val viewModel = ShoppingCartViewModel()
val testProduct = Product(id = "1", name = "Test", price = 10.0)
viewModel.addItem(testProduct)
val state = viewModel.cartState.value
assertEquals(1, state.itemCount)
assertEquals(10.0, state.totalPrice, 0.01)
}
@Test
fun `removing item decreases cart count`() = runTest {
val viewModel = ShoppingCartViewModel()
val testProduct = Product(id = "1", name = "Test", price = 10.0)
viewModel.addItem(testProduct)
viewModel.removeItem(testProduct.id)
val state = viewModel.cartState.value
assertEquals(0, state.itemCount)
assertEquals(0.0, state.totalPrice, 0.01)
}
}Testing benefits:
- State logic is isolated in ViewModels
- Easy to verify state transformations
- No UI testing needed for business logic
- Fast, reliable tests
Common Pitfalls to Avoid
Let me save you some headaches by pointing out common mistakes:
Pitfall 1: Unnecessary Recompositions
// Bad: Creates a new list on every recomposition
@Composable
fun BadExample() {
val items = listOf("A", "B", "C") // New list every time!
}
// Good: Remember the list
@Composable
fun GoodExample() {
val items = remember { listOf("A", "B", "C") }
}Pitfall 2: Calling Suspend Functions Directly
// Bad: This will crash
@Composable
fun BadNetworkCall() {
val data = repository.getData() // Suspend function!
}
// Good: Use LaunchedEffect
@Composable
fun GoodNetworkCall(viewModel: MyViewModel) {
val data by viewModel.data.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadData()
}
}Pitfall 3: Modifying State Outside Composition
// Bad: State update in initialization
@Composable
fun BadStateUpdate(viewModel: MyViewModel) {
viewModel.updateState() // Don't do this..!
}
// Good: Use side effects
@Composable
fun GoodStateUpdate(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.updateState()
}
}Best Practices for State Management in Jetpack Compose
Here are proven guidelines used in real production apps:
- Use a single UI state per screen
- Keep state immutable
- Expose state as
StateFlow - Handle side effects explicitly
- Avoid mutable state in Composables
- Let ViewModel own the logic
Following these keeps your app stable and testable.
Conclusion
Modern State Management in Jetpack Compose is not complicated once you understand the core ideas. State flows down. Events flow up. Side effects are handled explicitly.
Jetpack Compose rewards clean thinking. If your state is simple and predictable, your UI will be too.
Start small. Keep state clear. Let Compose do the heavy lifting.
