Skip to content
All posts
May 22, 20265 min read

Jetpack Compose State Management: Patterns That Scale from Day One

A practical guide to state management in Jetpack Compose with real code examples, showing how to choose the right pattern for your app's complexity and avoid common pitfalls that lead to bugs and maintenance headaches.

AndroidKotlinJetpack ComposeBest Practices
Share:

State management in Jetpack Compose isn't just about keeping the UI in sync—it's about building apps that survive real-world usage and team growth. After shipping 22 apps as a solo developer, I've seen too many projects crumble under state-related bugs when complexity hits.

The problem manifests quickly: UI lags behind data changes, state gets duplicated across components, or worse, business logic leaks into composables. These issues compound as your app grows, turning simple features into debugging nightmares.

Understanding State in Compose

Compose introduces a paradigm shift from traditional Android views. Instead of manipulating views directly, you describe UI based on state, and Compose handles the rendering. This means state becomes the single source of truth.

State in Compose can be categorized into three types:

State TypeScopeExampleManagement
UI StateSingle ScreenTextField value, dropdown expanded
code
mutableStateOf()
Screen StateOne Navigation DestinationUser profile data, loading statusViewModel + StateFlow
App StateEntire ApplicationUser session, theme preferencesRepository + DataStore

[!TIP] Keep state as close to where it's used as possible. Local UI state shouldn't bubble up unless siblings need to coordinate.

Here's how to identify the right scope:

kotlin
// Local UI State - stays in the composable
@Composable
fun ExpandableCard() {
    var isExpanded by remember { mutableStateOf(false) }
    
    Card(
        onClick = { isExpanded = !isExpanded }
    ) {
        Text(if (isExpanded) "Expanded content" else "Collapsed")
    }
}

State Management Patterns

As complexity grows, you need structured approaches. Here are the patterns I use in production:

Pattern 1: StateHolder (Simple Cases)

For self-contained components with minimal complexity:

kotlin
class UserProfileState(
    val repository: UserRepository
) {
    var name by mutableStateOf("")
    var email by mutableStateOf("")
    var isLoading by mutableStateOf(false)
        private set
    
    suspend fun loadUser(userId: String) {
        isLoading = true
        try {
            val user = repository.getUser(userId)
            name = user.name
            email = user.email
        } finally {
            isLoading = false
        }
    }
}

@Composable
fun UserProfileScreen() {
    val state = remember { UserProfileState(UserRepository()) }
    
    if (state.isLoading) {
        CircularProgressIndicator()
    } else {
        Text("Name: ${state.name}")
        Text("Email: ${state.email}")
    }
}

Pattern 2: ViewModel + StateFlow (Screen Level)

This is my go-to for screens with network calls or complex business logic:

kotlin
@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel() {
    
    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
    
    data class ProfileUiState(
        val user: User? = null,
        val isLoading: Boolean = false,
        val error: String? = null
    )
    
    fun loadProfile(userId: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true, error = null) }
            try {
                val user = repository.getUser(userId)
                _uiState.update { it.copy(user = user, isLoading = false) }
            } catch (e: Exception) {
                _uiState.update { it.copy(error = e.message, isLoading = false) }
            }
        }
    }
}

@Composable
fun ProfileScreen(
    viewModel: ProfileViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    
    ProfileContent(uiState)
}

@Composable
private fun ProfileContent(state: ProfileUiState) {
    when {
        state.isLoading -> CircularProgressIndicator()
        state.error != null -> Text("Error: ${state.error}")
        state.user != null -> Text(state.user.name)
    }
}

Pattern 3: State Container (Complex Coordination)

For coordinating multiple state sources or complex interactions:

kotlin
class ShoppingCartState(
    private val cartRepository: CartRepository,
    private val analytics: Analytics
) : ViewModel() {
    
    private val _cartItems = MutableStateFlow<List<CartItem>>(emptyList())
    private val _checkoutProgress = MutableStateFlow<CheckoutProgress>(CheckoutProgress.Idle)
    
    val cartItems: StateFlow<List<CartItem>> = _cartItems.asStateFlow()
    val checkoutProgress: StateFlow<CheckoutProgress> = _checkoutProgress.asStateFlow()
    
    val totalAmount by cartItems.map { items ->
        items.sumOf { it.price * it.quantity }
    }.collectAsStateInViewModel()
    
    init {
        loadCart()
    }
    
    private fun loadCart() {
        viewModelScope.launch {
            cartRepository.getCartItems().collect(_cartItems)
        }
    }
    
    fun addToCart(item: CartItem) {
        viewModelScope.launch {
            cartRepository.addItem(item)
            analytics.logEvent("item_added", mapOf("item_id" to item.id))
        }
    }
    
    fun checkout() {
        viewModelScope.launch {
            _checkoutProgress.value = CheckoutProgress.Processing
            try {
                cartRepository.checkout()
                _checkoutProgress.value = CheckoutProgress.Success
            } catch (e: Exception) {
                _checkoutProgress.value = CheckoutProgress.Error(e.message ?: "Unknown error")
            }
        }
    }
}

Handling Side Effects

Side effects in Compose require careful consideration. They should be lifecycle-aware and cancellable:

kotlin
@Composable
fun NotificationEffect(
    scope: CoroutineScope = rememberCoroutineScope(),
    navigateToList: () -> Unit
) {
    val context = LocalContext.current
    
    LaunchedEffect(key1 = Unit) {
        // Runs once when entering composition
        doSomethingImportant()
    }
    
    DisposableEffect(key1 = "analytics") {
        val job = scope.launch {
            analytics.logScreenView("ProfileScreen")
        }
        
        onDispose {
            job.cancel()
        }
    }
    
    // Navigation effect based on state
    val uiState by viewModel.uiState.collectAsState()
    LaunchedEffect(uiState.isSuccess) {
        if (uiState.isSuccess) {
            navigateToList()
        }
    }
}

[!WARNING] Never launch coroutines from composables without proper lifecycle awareness. Use

code
LaunchedEffect
,
code
rememberCoroutineScope
, or
code
ViewModel
scope to avoid memory leaks.

For one-time events like navigation or snackbars, use event channels:

kotlin
@HiltViewModel
class EventViewModel @Inject constructor() : ViewModel() {
    private val _events = MutableLiveData<Event<Unit>>()
    val events: LiveData<Event<Unit>> = _events
    
    fun navigateToSettings() {
        _events.value = Event(Unit)
    }
}

// In your activity or fragment
viewModel.events.observe(this) { event ->
    event.getContentIfNotHandled()?.let {
        navigateToSettings()
    }
}

State Persistence Strategies

Persisting state across app restarts is crucial for user experience:

kotlin
@HiltViewModel
class SettingsViewModel @Inject constructor(
    private val dataStore: DataStore<Preferences>
) : ViewModel() {
    
    private object PreferencesKeys {
        val THEME_MODE = preferencesKey<String>("theme_mode")
        val ONBOARDING_COMPLETE = preferencesKey<Boolean>("onboarding_complete")
    }
    
    val themeMode = dataStore.data
        .map { preferences ->
            preferences[PreferencesKeys.THEME_MODE] ?: "light"
        }
        .onEach { mode ->
            // Apply theme changes
            updateTheme(mode)
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "light")
    
    suspend fun updateTheme(mode: String) {
        dataStore.edit { preferences ->
            preferences[PreferencesKeys.THEME_MODE] = mode
        }
    }
}

Key Takeaways

  • Start simple: Use
    code
    mutableStateOf()
    for local UI state, but migrate to ViewModel + StateFlow when screens grow beyond basic interactions
  • Separate concerns: Keep UI state, screen state, and app state in appropriate layers—don't mix business logic with presentation logic
  • Handle side effects intentionally: Use
    code
    LaunchedEffect
    for UI-triggered effects and ViewModel scope for business logic, always respecting the lifecycle
  • Persist strategically: Use DataStore for user preferences and settings, but avoid persisting transient UI states that can be restored from the source of truth
  • Test state flows: Write unit tests for your ViewModels and state holders to verify state transitions and business logic without launching Activities
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this