Modern State Management in Jetpack Compose: Flows, Side Effects, and UI State

Table of Contents

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.

Kotlin
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.

Kotlin
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"
            )
        }
    }
}
  • MutableStateFlow holds mutable state inside the ViewModel
  • StateFlow is 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.

Kotlin
@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:

Kotlin
@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 when searchQuery changes
  • 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:

Kotlin
@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
  • onDispose runs 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:

Kotlin
@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:

  • SideEffect runs 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:

Kotlin
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 totalPrice are 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:

Kotlin
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,

  • combine merges multiple flows into one
  • Whenever either source flow emits, the lambda recalculates the state
  • stateIn converts the regular flow to StateFlow
  • WhileSubscribed(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:

Kotlin
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:

Kotlin
@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.data only 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)

Kotlin
@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)

Kotlin
@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:

  • SearchBar is 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

Kotlin
@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 remember for temporary UI state that can be regenerated
  • Use rememberSaveable for user input or important state
  • Both are cleared when the composable leaves composition permanently

rememberCoroutineScope for Manual Control

Kotlin
@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:

Kotlin
@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:

Kotlin
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

Kotlin
// 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

Kotlin
// 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

Kotlin
// 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.

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!