Jetpack Compose

State Management in Jetpack Compose

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

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.

Composition Over Inheritance

What Is Composition Over Inheritance? The Built-In Compose Way Explained

If you’ve been writing object-oriented code for a while, you’ve probably used inheritance a lot. It feels natural. You create a base class, extend it, override a few methods, and move on.

But as projects grow, inheritance often becomes hard to manage. Classes get tightly coupled. Changes ripple through the codebase. Small tweaks break unexpected things.

This is where Composition Over Inheritance comes in.

In this post, we’ll break down what Composition Over Inheritance really means, why it matters, and how it’s used naturally in modern Kotlin development, especially with Jetpack Compose. 

What Does “Composition Over Inheritance” Mean?

Composition Over Inheritance is a design principle that says:

Prefer building classes by combining smaller, reusable components instead of extending base classes.

In simpler terms:

  • Inheritance says “is a”
  • Composition says “has a”

Instead of forcing behavior through class hierarchies, you compose behavior by using other objects.

A Simple Real-World Example

Think of a smartphone.

A smartphone has a camera, battery, speaker, and screen.

It does not inherit from Camera, Battery, or Speaker.

That’s composition.

If you used inheritance here, the design would fall apart fast.

The Problem With Inheritance

Inheritance looks clean at first, but it comes with hidden costs.

Example Using Inheritance (Problematic)

Kotlin
open class Vehicle {
    open fun move() {
        println("Vehicle is moving")
    }
}

open class Car : Vehicle() {
    override fun move() {
        println("Car is driving")
    }
}

class ElectricCar : Car() {
    override fun move() {
        println("Electric car is driving silently")
    }
}

At first glance, this seems fine.

But now imagine:

  • You want a flying car
  • You want a boat-car
  • You want a self-driving electric truck

Your inheritance tree explodes.

Changes to Vehicle affect every subclass. You’re locked into decisions you made early, often before requirements were clear.

This is exactly what Composition Over Inheritance helps you avoid.

Composition Over Inheritance Explained With Kotlin

Let’s rewrite the same idea using composition.

Create Small, Focused Behaviors

Kotlin
interface Engine {
    fun move()
}
Kotlin
class GasEngine : Engine {
    override fun move() {
        println("Driving using gas engine")
    }
}
Kotlin
class ElectricEngine : Engine {
    override fun move() {
        println("Driving silently using electric engine")
    }
}

Each class has one clear responsibility.

Compose the Behavior

Kotlin
class Car(private val engine: Engine) {

    fun drive() {
        engine.move()
    }
}

Now the Car has an engine, instead of being forced into a rigid hierarchy.

Use It

Kotlin
fun main() {
    val electricCar = Car(ElectricEngine())
    electricCar.drive()

    val gasCar = Car(GasEngine())
    gasCar.drive()
}

Output:

Driving silently using electric engine
Driving using gas engine

This is Composition Over Inheritance in action.

Why Composition Over Inheritance Is Better

Here’s why modern Kotlin developers strongly prefer this approach.

1. Less Coupling

Your classes depend on interfaces, not concrete implementations.

2. Easier Changes

You can swap behaviors without rewriting class hierarchies.

3. Better Testability

You can inject fake or mock implementations easily.

4. Cleaner Code

Smaller classes. Clear responsibilities. Fewer surprises.

Composition Over Inheritance in Jetpack Compose

Jetpack Compose is built almost entirely on Composition Over Inheritance.

That’s not an accident.

Traditional UI (Inheritance-Heavy)

Kotlin
class CustomButton : Button {
    // override styles, behavior, states
}

This leads to rigid UI components that are hard to reuse.

Compose Way (Composition First)

Kotlin
@Composable
fun MyButton(
    text: String,
    onClick: () -> Unit
) {
    Button(onClick = onClick) {
        Text(text)
    }
}

Here’s what’s happening:

  • MyButton is not extending Button
  • It uses Button
  • Behavior is passed in, not inherited

This is Composition Over Inheritance at the UI level.

Why Compose Feels Easier to Work With

Compose avoids deep inheritance trees entirely.

Instead:

  • UI is built from small composable functions
  • Each function does one thing
  • You combine them like building blocks

That’s composition by design.

Delegation: Kotlin’s Built-In Support for Composition

Kotlin makes Composition Over Inheritance even easier with delegation.

Example Using Delegation

Kotlin
interface Logger {
    fun log(message: String)
}

class ConsoleLogger : Logger {
    override fun log(message: String) {
        println(message)
    }
}

class UserService(private val logger: Logger) : Logger by logger

Now UserService automatically uses ConsoleLogger’s implementation without inheritance.

This keeps your code flexible and clean.

When Should You Still Use Inheritance?

Inheritance is not evil. It’s just often overused.

Inheritance works best when:

  • There is a true “is-a” relationship
  • The base class is stable
  • You control both parent and child classes

If those conditions are missing, Composition Over Inheritance is usually the safer choice.

Conclusion

Let’s wrap it up.

  • Composition Over Inheritance means building behavior using objects, not class hierarchies
  • Kotlin makes composition easy with interfaces and delegation
  • Jetpack Compose is a real-world example of this principle done right
  • Composition leads to flexible, testable, and maintainable code

If you’re writing Kotlin today, especially with Compose, you’re already using Composition Over Inheritance whether you realized it or not.

And once you start designing with it intentionally, your code gets simpler, not harder.

ViewModel and rememberSaveable

The Truth About ViewModel and rememberSavable: Configuration Changes vs Process Death

If you’ve built Android apps with Jetpack Compose, you’ve probably run into the question: Should I use ViewModel or rememberSaveable? Both help you keep state alive, but they work very differently depending on what’s happening to your app — like when the screen rotates or when the system kills your process.

This post will break down ViewModel and rememberSaveable, explain when to use each, and show real code examples so it finally clicks.

The Basics: Why State Preservation Matters

On Android, your app doesn’t always stay alive. Two big events affect your app’s state:

  1. Configuration changes — like screen rotations, language changes, or switching dark mode. The activity is destroyed and recreated, but the process usually stays alive.
  2. Process death — when Android kills your app’s process (e.g., to reclaim memory) and later restores it when the user comes back.

If you don’t handle these correctly, your users lose whatever they were doing. That’s where ViewModel and rememberSaveable come in.

remember: The Starting Point in Compose

At the simplest level, you use remember in Jetpack Compose to keep state alive across recompositions.

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

    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}
  • Here, count won’t reset when Compose redraws the UI.
  • But if the device rotates (configuration change), the state is lost because remember only survives recompositions, not activity recreation.

That’s why we need more powerful tools.

rememberSaveable: Survives Configuration Changes and Process Death

rememberSaveable goes one step further. It automatically saves your state into a Bundle using Android’s saved instance state mechanism.

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

    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

What happens here:

  • Rotate the screen? count survives.
  • App is killed and restored (process death)? count also survives, because it was written to the saved instance state.

Limitations:

  • Only works with data types that can be written to a Bundle (primitives, Strings, parcelables, etc.).
  • Not ideal for large objects or data fetched from a repository.

ViewModel: Survives Configuration Changes, Not Process Death

A ViewModel is a lifecycle-aware container designed to hold UI data. It’s tied to a LifecycleOwner like an activity or a navigation back stack entry.

Kotlin
class CounterViewModel : ViewModel() {
    var count by mutableStateOf(0)
}

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    Button(onClick = { viewModel.count++ }) {
        Text("Count: ${viewModel.count}")
    }
}

What happens here:

  • Rotate the screen? count survives. The same ViewModel instance is reused.
  • App is killed (process death)? count is lost. The ViewModel does not persist beyond process death.

Configuration Changes vs Process Death: Who Wins?

Here’s the clear breakdown:

When to Use rememberSaveable

Use rememberSaveable for small, lightweight UI state that:

  • Must survive both rotation and process death.
  • Can easily be serialized into a Bundle.

Examples:

  • Current tab index.
  • Form text fields.
  • Simple filter/sort options.

When to Use ViewModel

Use ViewModel for more complex or long-lived state that:

  • Doesn’t need to survive process death.
  • Might involve business logic, repositories, or data streams.
  • Should be scoped to the screen or navigation graph.

Examples:

  • Data loaded from a database or network.
  • Complex business logic.
  • State shared across multiple composables in the same screen.

Can You Combine Them? Yes.

Often, the best solution is to use ViewModel and rememberSaveable together.
 For example, a ViewModel manages your main UI state, but a few critical fields use rememberSaveable so they’re restored even after process death.

Kotlin
@Composable
fun FormScreen(viewModel: FormViewModel = viewModel()) {
    var userInput by rememberSaveable { mutableStateOf("") }

    Column {
        TextField(
            value = userInput,
            onValueChange = { userInput = it }
        )

        Button(onClick = { viewModel.submit(userInput) }) {
            Text("Submit")
        }
    }
}

Here:

  • userInput is lightweight and saved with rememberSaveable.
  • The ViewModel takes care of processing and persisting the submitted data.

Conclusion

The truth about ViewModel and rememberSaveable is simple once you think in terms of configuration changes vs process death:

  • remember → Only survives recomposition.
  • rememberSaveable → Survives both rotation and process death (small, serializable state).
  • ViewModel → Survives rotation, great for business logic, but not process death.

Use them in combination, not competition. Each tool has its place, and knowing when to reach for which makes your Compose apps more resilient, smoother, and user-friendly.

CompositionLocal

CompositionLocal Deep Dive: Writing Scalable UI in Jetpack Compose

When building Android apps with Jetpack Compose, you’ll often need to share data across your UI tree. Passing parameters down every Composable quickly becomes messy. That’s where CompositionLocal comes in.

Think of CompositionLocal as a smart way to provide values (like theme, locale, or user preferences) to multiple Composables without having to manually thread them through function parameters. It’s like dependency injection — but scoped to the Compose world.

In this post, we’ll explore how CompositionLocal works, why it matters for building scalable UI, and how you can use it effectively.

What is CompositionLocal?

CompositionLocal is a mechanism that allows you to define and access values that are automatically propagated down the Composable hierarchy.

  • It provides contextual values (like theme colors or configurations).
  • It removes the need to pass arguments everywhere.
  • It helps you scale UI architecture by keeping components decoupled.

Jetpack Compose already uses CompositionLocal under the hood for things like MaterialTheme, text styles, and layout direction.

Defining a CompositionLocal

You start by creating a CompositionLocal with a default value:

Kotlin
val LocalUser = compositionLocalOf<String> { 
    error("No user provided") 
}

Here:

  • compositionLocalOf creates a CompositionLocal with a default (or error if missing).
  • We’re saying: “If no user is provided, throw an error.”

Providing a Value

To inject a value, you use CompositionLocalProvider:

Kotlin
@Composable
fun AppContent() {
    CompositionLocalProvider(LocalUser provides "amol pawar") {
        UserProfile()
    }
}

Inside AppContent, any child Composable can access LocalUser.

Consuming a CompositionLocal

To read the value, use .current:

Kotlin
@Composable
fun Dashboard() {
    Column {
        CompositionLocalProvider(LocalUser provides "akshay") {
            UserProfile() // shows "Hello, askhay!"
        }
        UserProfile() // shows "Hello, amol pawar!"
    }
}

Output:

Hello, amol pawar!

No need to pass user down as a parameter—CompositionLocal handles it.

Why Use CompositionLocal?

Let’s break it down with a practical example. Imagine a large app with:

  • Theme data (colors, typography).
  • User session info.
  • App settings like dark mode, locale, etc.

Passing these manually would be a nightmare. With CompositionLocal, you define them once and let the UI tree consume them where needed.

Scoped Values for Flexibility

One powerful feature is scoping. You can override a CompositionLocal in a subtree without affecting the rest of the app.

Kotlin
@Composable
fun Dashboard() {
    Column {
        CompositionLocalProvider(LocalUser provides "akshay") {
            UserProfile() // shows "Hello, askhay!"
        }
        UserProfile() // shows "Hello, amol pawar!"
    }
}

The value depends on where the Composable is in the hierarchy. This makes it perfect for context-specific overrides (like previewing different themes).

Best Practices for CompositionLocal

  1. Don’t abuse it. Use CompositionLocal for global or contextual data, not just to avoid passing parameters.
  2. Keep defaults meaningful. Provide safe defaults or throw an error if the value is critical.
  3. Use for ambient context. Theme, locale, user, system settings — these are ideal use cases.
  4. Avoid hidden dependencies. If a Composable always needs a value, prefer explicit parameters for clarity.

Theme System with CompositionLocal

Let’s create a mini theme system:

Kotlin
data class MyColors(val primary: Color, val background: Color)

val LocalColors = staticCompositionLocalOf<MyColors> {
    error("No colors provided")
}

@Composable
fun MyTheme(content: @Composable () -> Unit) {
    val colors = MyColors(primary = Color.Blue, background = Color.White)
    CompositionLocalProvider(LocalColors provides colors) {
        content()
    }
}

@Composable
fun ThemedButton() {
    val colors = LocalColors.current
    Button(onClick = {}) {
        Text("Click Me", color = colors.primary)
    }
}

Usage:

Kotlin
@Composable
fun App() {
    MyTheme {
        ThemedButton()
    }

Here, ThemedButton gets its styling from LocalColors without needing parameters.

CompositionLocal vs Parameters

  • Use parameters when data is essential to the Composable.
  • Use CompositionLocal when data is contextual, like theming or configuration.

This balance keeps your UI scalable and maintainable.

Conclusion

CompositionLocal is one of the most powerful tools in Jetpack Compose for writing scalable UI. It keeps your code cleaner, reduces boilerplate, and makes context handling a breeze.

By using CompositionLocal wisely, you can:

  • Share contextual data easily
  • Override values locally
  • Keep UI components decoupled and reusable

Next time you’re passing a value through five different Composables, stop and ask yourself — could CompositionLocal handle this better?

Scrollable Sticky Table/Grid UIs in Jetpack Compose

Build Scrollable Sticky Table/Grid UIs in Jetpack Compose Like a Pro

If you’ve ever built dashboards, spreadsheets, or financial apps, you know how important tables are. But a plain table isn’t enough — you often need a Scrollable Sticky Table/Grid where headers stay in place while data scrolls smoothly.

In the past, building this in Android XML layouts was painful. With Jetpack Compose, you can achieve it with clean Kotlin code, no hacks, and full flexibility.

In this guide, we’ll walk through how to build a professional Scrollable Sticky Table/Grid in Jetpack Compose — from the basics to a fully data-driven version that adapts to any dataset.

Why Scrollable Sticky Tables Matter

A Scrollable Sticky Table/Grid is more than eye candy. It solves real usability problems:

  • Sticky headers: Keep column labels visible while scrolling.
  • Row headers: Let users track rows without losing context.
  • Independent scrolls: Row IDs can scroll separately from the table, making it easier to navigate large datasets.
  • Dynamic structure: Tables should adapt to n rows and m columns—no hardcoding.

Think Google Sheets, Excel, or analytics dashboards. The same principles apply here.

A Basic Scrollable Table

Let’s start with the simplest version: rows and columns that scroll.

Kotlin
@Composable
fun ScrollableGridDemo() {
    val rowCount = 20
    val columnCount = 10

    LazyColumn {
        items(rowCount) { rowIndex ->
            Row(
                modifier = Modifier
                    .horizontalScroll(rememberScrollState())
            ) {
                repeat(columnCount) { colIndex ->
                    Box(
                        modifier = Modifier
                            .size(100.dp)
                            .border(1.dp, Color.Gray)
                            .padding(8.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text("R$rowIndex C$colIndex")
                    }
                }
            }
        }
    }
}

This gives you a grid that scrolls vertically and horizontally. But headers vanish when you scroll.

Adding Sticky Column Headers

With stickyHeader, we can lock the top row.

Kotlin
@Composable
fun ScrollableStickyTable() {
    val rowCount = 20
    val columnCount = 10

    LazyColumn {
        // Sticky Header Row
        stickyHeader {
            Row(
                modifier = Modifier
                    .background(Color.LightGray)
                    .horizontalScroll(rememberScrollState())
            ) {
                repeat(columnCount) { colIndex ->
                    Box(
                        modifier = Modifier
                            .size(100.dp)
                            .border(1.dp, Color.Black)
                            .padding(8.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text("Header $colIndex", fontWeight = FontWeight.Bold)
                    }
                }
            }
        }

        // Table Rows
        items(rowCount) { rowIndex ->
            Row(
                modifier = Modifier
                    .horizontalScroll(rememberScrollState())
            ) {
                repeat(columnCount) { colIndex ->
                    Box(
                        modifier = Modifier
                            .size(100.dp)
                            .border(1.dp, Color.Gray)
                            .padding(8.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text("R$rowIndex C$colIndex")
                    }
                }
            }
        }
    }
}

Headers now stick to the top as you scroll.

Adding Row Headers

Sometimes you need a first column (row IDs) that doesn’t disappear when you scroll horizontally. The trick is to split the table into two sections:

  • Row header column → vertical scroll only.
  • Main table → vertical + horizontal scroll.

And make them sit side by side.

Making It Dynamic (n Rows, m Columns)

Hardcoding row and column counts isn’t practical. Let’s build a reusable, data-driven composable.

Kotlin
@Composable
fun DataDrivenStickyTable(
    rowHeaders: List<String>,            // Row labels
    columnHeaders: List<String>,         // Column labels
    tableData: List<List<String>>        // 2D grid of values [row][col]
) {
    Row {
        // Row Header Column
        LazyColumn(
            modifier = Modifier.width(100.dp)
        ) {
            // Sticky Row Header Title
            stickyHeader {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(50.dp)
                        .background(Color.DarkGray)
                        .border(1.dp, Color.Black),
                    contentAlignment = Alignment.Center
                ) {
                    Text("Row#", color = Color.White, fontWeight = FontWeight.Bold)
                }
            }

            // Dynamic Row Headers
            items(rowHeaders.size) { rowIndex ->
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(50.dp)
                        .background(Color.Gray)
                        .border(1.dp, Color.Black),
                    contentAlignment = Alignment.Center
                ) {
                    Text(rowHeaders[rowIndex], fontWeight = FontWeight.Medium)
                }
            }
        }

        // Main Table
        LazyColumn(
            modifier = Modifier.weight(1f)
        ) {
            // Sticky Column Headers
            stickyHeader {
                Row(
                    modifier = Modifier
                        .horizontalScroll(rememberScrollState())
                        .background(Color.LightGray)
                ) {
                    columnHeaders.forEach { header ->
                        Box(
                            modifier = Modifier
                                .size(width = 100.dp, height = 50.dp)
                                .border(1.dp, Color.Black),
                            contentAlignment = Alignment.Center
                        ) {
                            Text(header, fontWeight = FontWeight.Bold)
                        }
                    }
                }
            }

            // Dynamic Rows
            items(rowHeaders.size) { rowIndex ->
                Row(
                    modifier = Modifier
                        .horizontalScroll(rememberScrollState())
                ) {
                    tableData[rowIndex].forEach { cell ->
                        Box(
                            modifier = Modifier
                                .size(width = 100.dp, height = 50.dp)
                                .border(1.dp, Color.LightGray),
                            contentAlignment = Alignment.Center
                        ) {
                            Text(cell)
                        }
                    }
                }
            }
        }
    }
}

You can generate as many rows and columns as you want dynamically:

Kotlin
@Composable
fun TableDemo() {
    val rowHeaders = List(20) { "Row $it" }
    val columnHeaders = List(10) { "Col $it" }
    val tableData = List(rowHeaders.size) { rowIndex ->
        List(columnHeaders.size) { colIndex ->
            "R$rowIndex C$colIndex"
        }
    }

    DataDrivenStickyTable(
        rowHeaders = rowHeaders,
        columnHeaders = columnHeaders,
        tableData = tableData
    )
}

Now your table works with any dataset — whether it’s 5×5 or 100×100.

Check out the complete project on my GitHub repository

Performance Tips

  • Use LazyColumn + LazyRow for large datasets—they recycle views efficiently.
  • If your dataset is small, you can simplify with Column + Row.
  • Use rememberLazyListState() and rememberScrollState() if you need to sync scrolling between row headers and table content.

Conclusion

With Jetpack Compose, building a Scrollable Sticky Table/Grid is no longer a headache. You can:

  • Show sticky headers for both rows and columns.
  • Keep row headers independent or sync them with the table.
  • Dynamically generate n rows and m columns from real datasets.

This approach is clean, scalable, and production-ready. The next time you need a spreadsheet-like UI, you’ll know exactly how to do it — like a pro.

Jetpack Compose LazyColumn Sticky Header

Jetpack Compose LazyColumn Sticky Header: Complete Implementation Guide

When you’re building long lists in Jetpack Compose, sometimes you need certain sections to stand out and stay visible while scrolling. That’s exactly where Sticky Header comes in. Imagine scrolling through a contacts app — the alphabet letter headers (A, B, C…) stick at the top while you browse through names. Jetpack Compose makes this easy with LazyColumn and stickyHeader.

In this guide, I’ll walk you through how to implement Sticky Header in Jetpack Compose with clear explanations.

What is a Sticky Header?

A Sticky Header is a UI element that “sticks” at the top of a scrollable list until the next header pushes it off. It’s commonly used in:

  • Contact lists
  • Calendar apps
  • Shopping category lists
  • News feeds with date separators

This improves navigation and makes large lists easier to scan.

Why Use Sticky Header in Jetpack Compose?

With Jetpack Compose, you don’t need RecyclerView adapters or complex custom views. The LazyColumn component handles large, scrollable lists efficiently, and stickyHeader makes adding sticky sections straightforward.

Benefits:

  • Simple syntax, no XML layouts.
  • Clean and declarative code.
  • Works seamlessly with Compose state management.

LazyColumn and stickyHeader Basics

Here’s the basic structure of a LazyColumn with a Sticky Header:

Kotlin
@Composable
fun StickyHeaderExample() {
    val sections = listOf(
        "Fruits" to listOf("Apple", "Banana", "Orange"),
        "Vegetables" to listOf("Carrot", "Potato", "Tomato"),
        "Dairy" to listOf("Milk", "Cheese", "Yogurt")
    )

    LazyColumn {
        sections.forEach { (header, items) ->
            stickyHeader {
                Text(
                    text = header,
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(Color.LightGray)
                        .padding(8.dp),
                    style = MaterialTheme.typography.subtitle1
                )
            }

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

Let’s break it down:

Data Setup

Kotlin
val sections = listOf(
    "Fruits" to listOf("Apple", "Banana", "Orange"),
    "Vegetables" to listOf("Carrot", "Potato", "Tomato"),
    "Dairy" to listOf("Milk", "Cheese", "Yogurt")
)

Here, each section has a header (like “Fruits”) and a list of items.

LazyColumn

Kotlin
LazyColumn { ... }

Displays the entire list efficiently. Only visible items are composed, so it’s memory-friendly.

stickyHeader

Kotlin
stickyHeader {
    Text(
        text = header,
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.LightGray)
            .padding(8.dp)
    )
}

This is the star of the show. The header stays pinned at the top while scrolling through its section.

items()

Kotlin
items(items) { item -> ... }

Renders each element under the sticky header.

Customizing Sticky Headers

You can style sticky headers to fit your app’s design. For example:

  • Add icons to headers.
  • Change background color based on section.
  • Apply elevation or shadows for better separation.

Example with custom styling:

Kotlin
stickyHeader {
    Surface(
        color = Color.DarkGray,
        shadowElevation = 4.dp
    ) {
        Text(
            text = header,
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            color = Color.White,
            style = MaterialTheme.typography.h6
        )
    }
}

When to Use and When Not To

Use Sticky Header when:

  • The list is grouped (categories, dates, sections).
  • Users need quick context while scrolling.

Avoid Sticky Header if:

  • The list is flat (no categories).
  • Too many headers clutter the UI.

Performance Considerations

LazyColumn with stickyHeader is optimized, but keep these in mind:

  • Keep headers lightweight (avoid heavy Composables inside).
  • Reuse stateful items outside of the list when possible.
  • Test on lower-end devices if you have very large datasets.

Conclusion

The Sticky Header in Jetpack Compose makes complex, sectioned lists much easier to build and navigate. With just a few lines of code inside a LazyColumn, you can create polished, user-friendly experiences without dealing with RecyclerView boilerplate.

If you’re building apps with grouped data — contacts, shopping categories, or event timelines — Sticky Header is a feature you’ll definitely want to use.

Sticky Header in Jetpack Compose

How to Create a Sticky Header in Jetpack Compose

If you’ve ever scrolled through a long list in an app and noticed that the section title stays pinned at the top until the next section appears, you’ve seen a sticky header. Sticky headers make lists easier to navigate, especially when content is grouped by category.

In this post, we’ll learn step by step how to implement a Sticky Header in Jetpack Compose using LazyColumn. Don’t worry if you’re just getting started with Compose—the explanation will stay simple, with code examples and clear breakdowns.

What is a Sticky Header?

A sticky header is a UI element that remains visible at the top of a scrolling list while the content beneath it scrolls. It’s often used in apps like Contacts (where the alphabet letters stick as you scroll) or e-commerce apps (where categories like “Shoes,” “Bags,” or “Clothing” stay pinned).

Jetpack Compose makes this much easier to implement compared to the old RecyclerView approach in XML.

Why Use Sticky Headers in Jetpack Compose?

Adding a Sticky Header in Jetpack Compose improves:

  • Readability: Users instantly know which section they’re in.
  • Navigation: Helps users scan through grouped content quickly.
  • User Experience: Feels modern, polished, and professional.

The Key Composable: stickyHeader

Jetpack Compose provides a built-in modifier inside LazyColumn called stickyHeader. This allows you to define a composable item that “sticks” to the top while scrolling.

Basic Code Example

Here’s a simple example of creating a Sticky Header in Jetpack Compose:

Kotlin
@Composable
fun StickyHeaderList() {
    val groupedItems = mapOf(
        "Fruits" to listOf("Apple", "Banana", "Mango", "Orange"),
        "Vegetables" to listOf("Carrot", "Potato", "Tomato"),
        "Drinks" to listOf("Water", "Juice", "Soda")
    )

    LazyColumn {
        groupedItems.forEach { (header, items) ->
            stickyHeader {
                Text(
                    text = header,
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(Color.LightGray)
                        .padding(16.dp),
                    fontWeight = FontWeight.Bold
                )
            }

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

Let’s break it down so it’s crystal clear:

Grouped Data

  • We created a Map with categories as keys ("Fruits", "Vegetables", "Drinks") and a list of items under each.

LazyColumn

  • Works like a RecyclerView but in Compose. It’s efficient for large lists.

stickyHeader

  • This is the magic. Whatever you put inside stickyHeader will remain stuck at the top until another header replaces it.
  • We used a Text with background color and padding so it looks like a section header.

items()

  • Displays each element in the list under its header.

Styling the Sticky Header

You don’t want your sticky header to look boring. Here are a few tweaks you can add:

Kotlin
stickyHeader {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = Color.DarkGray,
        shadowElevation = 4.dp
    ) {
        Text(
            text = header,
            modifier = Modifier.padding(16.dp),
            color = Color.White,
            fontSize = 18.sp,
            fontWeight = FontWeight.Bold
        )
    }
}

This adds:

  • Background color (DarkGray)
  • Shadow elevation for depth
  • White text for contrast

When to Use Sticky Headers

Sticky headers are perfect for:

  • Contact lists grouped alphabetically
  • Shopping apps with categories
  • News apps with sections (e.g., Sports, Tech, Business)
  • Music playlists grouped by artist or album

Common Mistakes to Avoid

  • Too many sticky headers: Don’t overuse them — it can feel cluttered.
  • No visual distinction: Make sure headers look different from list items.
  • Performance issues: For extremely large datasets, consider lazy loading.

Conclusion

Creating a Sticky Header in Jetpack Compose is simple, thanks to the stickyHeader API inside LazyColumn. With just a few lines of code, you can build a smooth, user-friendly list that looks polished and professional.

As Compose continues to evolve, features like these make UI development faster, cleaner, and more intuitive. Whether you’re building a contacts app, a shopping app, or just experimenting, sticky headers will give your lists a better structure and improve the user experience.

Pro Tip: Always test on different screen sizes to make sure your headers remain clear and readable.

Now it’s your turn — try adding a sticky header to your own Jetpack Compose project and see the difference!

State Hoisting in Jetpack Compose

State Hoisting in Jetpack Compose: Best Practices for Scalable Apps

When building Android apps with Jetpack Compose, state management is one of the most important pieces to get right. If you don’t handle state properly, your UI can become messy, tightly coupled, and hard to scale. That’s where State Hoisting in Jetpack Compose comes in.

In this post, we’ll break down what state hoisting is, why it matters, and how you can apply best practices to make your Compose apps scalable, maintainable, and easy to debug.

What Is State Hoisting in Jetpack Compose?

In simple terms, state hoisting is the process of moving state up from a child composable into its parent. Instead of a UI component directly owning and mutating its state, the parent holds the state and passes it down, while the child only receives data and exposes events.

This separation ensures:

  • Reusability: Components stay stateless and reusable.
  • Single Source of Truth: State is managed in one place, reducing bugs.
  • Scalability: Complex UIs are easier to extend and test.

A Basic Example of State Hoisting

Let’s say you have a simple text field. Without state hoisting, the child manages its own state like this:

Kotlin
@Composable
fun SimpleTextField() {
    var text by remember { mutableStateOf("") }

    TextField(
        value = text,
        onValueChange = { text = it }
    )
}

This works fine for small apps, but the parent composable has no control over the value. It becomes difficult to coordinate multiple composables.

Now let’s apply state hoisting:

Kotlin
@Composable
fun SimpleTextField(
    text: String,
    onTextChange: (String) -> Unit
) {
    TextField(
        value = text,
        onValueChange = onTextChange
    )
}

And in the parent:

Kotlin
@Composable
fun ParentComposable() {
    var text by remember { mutableStateOf("") }

    SimpleTextField(
        text = text,
        onTextChange = { text = it }
    )
}

Here’s what changed

  • The parent owns the state (text).
  • The child only displays the state and sends updates back via onTextChange.

This is the core idea of State Hoisting in Jetpack Compose.

Why State Hoisting Matters for Scalable Apps

As your app grows, different UI elements will need to communicate. If each composable owns its own state, you’ll end up duplicating data or creating inconsistencies.

By hoisting state:

  • You centralize control, making it easier to debug.
  • You avoid unexpected side effects caused by hidden internal state.
  • You enable testing, since state management is separated from UI rendering.

Best Practices for State Hoisting in Jetpack Compose

1. Keep Composables Stateless When Possible

A good rule of thumb: UI elements should be stateless and only care about how data is displayed. The parent decides what data to provide.

Example: A button shouldn’t decide what happens when it’s clicked — it should simply expose an onClick callback.

2. Use remember Wisely in Parents

State is usually managed at the parent level using remember or rememberSaveable.

  • Use remember when state only needs to survive recomposition.
  • Use rememberSaveable when you want state to survive configuration changes (like screen rotations).
Kotlin
var text by rememberSaveable { mutableStateOf("") }

3. Follow the Unidirectional Data Flow Pattern

Compose encourages Unidirectional Data Flow (UDF):

  1. Parent owns state.
  2. State is passed down to child.
  3. Child emits events back to parent.

This clear flow makes apps predictable and avoids infinite loops or messy side effects.

4. Keep State Close to Where It’s Used, But Not Too Close

Don’t hoist all state to the top-level of your app. That creates unnecessary complexity. Instead, hoist it just far enough up so that all dependent composables can access it.

For example, if only one screen needs a piece of state, keep it inside that screen’s parent composable rather than in the MainActivity.

5. Use ViewModels for Shared State Across Screens

For larger apps, when multiple screens or composables need the same state, use a ViewModel.

Kotlin
class LoginViewModel : ViewModel() {
    var username by mutableStateOf("")
        private set

    fun updateUsername(newValue: String) {
        username = newValue
    }
}

Then in your composable:

Kotlin
@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel()) {
    SimpleTextField(
        text = viewModel.username,
        onTextChange = { viewModel.updateUsername(it) }
    )
}

This pattern keeps your UI clean and separates business logic from presentation.

Common Mistakes to Avoid

  • Keeping state inside deeply nested children: This makes it impossible to share or control at higher levels.
  • Over-hoisting: Don’t hoist state unnecessarily if no other composable needs it.
  • Mixing UI logic with business logic: Keep state handling in ViewModels where appropriate.

Conclusion

State Hoisting in Jetpack Compose is more than just a coding pattern — it’s the backbone of building scalable, maintainable apps. By lifting state up, following unidirectional data flow, and keeping components stateless, you set yourself up for long-term success.

To summarize:

  • Keep state in the parent, not the child.
  • Pass data down, send events up.
  • Use ViewModels for shared or complex state.

By applying these best practices, you’ll build apps that are not only functional today but also easy to scale tomorrow.

State Management in Jetpack Compose

Mastering State Management in Jetpack Compose: A Comprehensive Guide

State management is one of the most critical aspects of building dynamic and interactive Android applications. With Jetpack Compose, Android’s modern UI toolkit, managing state becomes more intuitive, but it also introduces new paradigms that developers need to understand. In this blog, we’ll explore state management in Jetpack Compose in detail. We’ll break down essential...

Membership Required

You must be a member to access this content.

View Membership Levels

Already a member? Log in here
collectAsStateWithLifecycle

Lifecycle-Aware State in Compose: Why collectAsStateWithLifecycle Outperforms collectAsState

Jetpack Compose makes UI state management feel almost magical — you observe a Flow, call collectAsState(), and your composable stays up to date.
 But here’s the catch: not all flows are equal when it comes to lifecycle awareness.

If you’re building Android apps today, you should almost always be reaching for collectAsStateWithLifecycle instead of the collectAsState.

Let’s break down why, with explanations, examples, and practical advice.

Understanding the Basics

What is collectAsState?

collectAsState() is an extension function in Jetpack Compose that collects values from a Flow (or StateFlow) and exposes them as Compose State.
 Every time the flow emits a new value, your composable re-renders with the updated data.

Kotlin
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    val userName by viewModel.userNameFlow.collectAsState(initial = "Loading...")
    Text(text = "Hello, $userName!")
}

Here, userNameFlow is a Flow<String> that might emit whenever the user’s name changes.

The Problem with collectAsState

collectAsState doesn’t know when your composable is visible. It starts collecting as soon as the composable enters the composition — and keeps doing so even if the screen is not in the foreground.

That means:

  • You could be running unnecessary background work.
  • Network calls or database queries might happen when the user isn’t looking.
  • Your app wastes CPU cycles and battery.

In other words, it’s not lifecycle-aware.

collectAsStateWithLifecycle

Google introduced collectAsStateWithLifecycle (in androidx.lifecycle:lifecycle-runtime-compose) to solve exactly this issue.

Instead of collecting forever, it automatically pauses collection when your composable’s lifecycle is not in a certain state — usually STARTED.

Kotlin
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    val userName by viewModel.userNameFlow.collectAsStateWithLifecycle(initialValue = "Loading...")
    Text(text = "Hello, $userName!")
}

The big difference? If the user navigates away from UserProfileScreen, the flow stops collecting until the screen comes back.

Why It’s Better — The Lifecycle Advantage

1. Automatic Lifecycle Awareness

You don’t need to manually tie your collection to the lifecycle. The function does it for you using Lifecycle.repeatOnLifecycle() under the hood.

2. Battery & Performance Friendly

Since it stops collecting when not visible, you avoid wasted CPU work, unnecessary recompositions, and background data processing.

3. Safe with Expensive Flows

If your flow triggers heavy database or network calls, collectAsStateWithLifecycle ensures they run only when needed.

4. Future-Proof Best Practice

Google’s Compose + Flow documentation now recommends lifecycle-aware collection as the default. This isn’t just a “nice-to-have” — it’s the right way to do it going forward.

Comparison

Let’s make it crystal clear:

FeaturecollectAsStatecollectAsStateWithLifecycle
Lifecycle-awareNoYes
Stops collecting when not visibleNoYes
Prevents wasted workNoYes
Recommended by GoogleNoYes

Code Walkthrough

ViewModel:

Kotlin
class UserProfileViewModel : ViewModel() {
    private val _userName = MutableStateFlow("Guest")
    val userNameFlow: StateFlow<String> = _userName

    init {
        // Simulate data updates
        viewModelScope.launch {
            delay(2000)
            _userName.value = "Alex"
        }
    }
}

Composable with collectAsState:

Kotlin
@Composable
fun UserProfileScreenLegacy(viewModel: UserProfileViewModel) {
    val userName by viewModel.userNameFlow.collectAsState() // Not lifecycle-aware
    Text("Hello, $userName")
}

If you navigate away from the screen, this still collects and recomposes unnecessarily.

Composable with collectAsStateWithLifecycle:

Kotlin
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
    val userName by viewModel.userNameFlow.collectAsStateWithLifecycle()
    Text("Hello, $userName")
}

Now, when the screen goes to the background, collection stops — no wasted updates.

When to Still Use collectAsState

collectAsStateWithLifecycle is better in most UI-bound cases.
 However, if you:

  • Need continuous background collection regardless of visibility, or
  • Are already handling lifecycle manually

…then collectAsState might be fine.

But for UI-driven flows, always prefer lifecycle-aware collection.

Conclusion

collectAsStateWithLifecycle isn’t just a small optimization — it’s an important shift toward writing responsible, lifecycle-safe Compose code.
 It keeps your app snappy, battery-friendly, and future-proof.

So next time you’re writing a Compose screen that collects from a Flow, skip the old habit. Reach for:

Kotlin
val state by flow.collectAsStateWithLifecycle()

Your users (and their batteries) will thank you.

error: Content is protected !!