Best Practices for Managing State in Android Apps with Kotlin Coroutines and Flow

Hazal Eroğlu
4 min readNov 4, 2024

Introduction

State management is a fundamental concept in app development, and it becomes even more crucial when building complex Android applications. With Kotlin Coroutines and Flow, we have modern tools to manage state efficiently and reactively. In this article, we’ll dive into best practices for using Coroutines and Flow to handle state in Android apps, focusing on building responsive, smooth, and efficient UIs.

Why Use Kotlin Coroutines and Flow for State Management?

In Android development, managing asynchronous data streams and UI state can be challenging. Traditional approaches often involve callbacks, which can lead to boilerplate code and callback hell. Kotlin Coroutines and Flow simplify asynchronous programming, making it easier to manage state and handle real-time data updates.

Benefits include:

  • Declarative Approach: Coroutines and Flow allow for a more declarative approach to managing UI state, which simplifies your code.
  • Concurrency Support: Coroutines are designed to manage concurrency efficiently, making them ideal for handling network requests and database operations.
  • Flow for Reactive Streams: Flow is great for managing continuous data streams and updates, ensuring that your UI is reactive to state changes.

Key Concepts in State Management with Coroutines and Flow

  • CoroutineScope: Defines the lifecycle of coroutines and controls when they’re canceled.
  • StateFlow: A state-holder observable that emits updates to subscribers whenever the state changes.
  • SharedFlow: A hot stream that can emit values to multiple subscribers.
  • ViewModelScope: A CoroutineScope tied to the ViewModel’s lifecycle, making it ideal for managing UI state without worrying about leaks.

Best Practices for State Management with Coroutines and Flow

1. Use StateFlow for UI State in ViewModels

StateFlow is a highly recommended way to manage state in ViewModels because it emits the latest value to any collector immediately upon subscription. This makes it ideal for holding UI state that should be consistent across configuration changes (like rotations).

class MainViewModel : ViewModel() {

private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState

fun loadData() {
viewModelScope.launch {
try {
val data = fetchData() // Assume fetchData() is a suspend function
_uiState.value = UiState.Success(data)
} catch (e: Exception) {
_uiState.value = UiState.Error(e)
}
}
}
}

sealed class UiState {
object Loading : UiState()
data class Success(val data: List<String>) : UiState()
data class Error(val exception: Throwable) : UiState()
}

Here:

  • We use MutableStateFlow for uiState to hold the state.
  • The viewModelScope.launch block handles async work (like fetching data) and updates the state accordingly.

2. Use SharedFlow for One-time Events

One-time events, such as navigation or showing a toast, should not be handled by StateFlow because it re-emits the last state to new collectors. Instead, use SharedFlow, which can emit values that are only collected once.

private val _eventFlow = MutableSharedFlow<UiEvent>()
val eventFlow: SharedFlow<UiEvent> = _eventFlow

fun triggerEvent(event: UiEvent) {
viewModelScope.launch {
_eventFlow.emit(event)
}
}

sealed class UiEvent {
object ShowToast : UiEvent()
data class Navigate(val destination: String) : UiEvent()
}

In this example:

  • SharedFlow is used to emit UiEvent’s, which will be collected only once by each subscriber.
  • Events like ShowToast or Navigate will trigger actions in the UI layer without being re-emitted.

3. Use collectAsState in Jetpack Compose

Jetpack Compose simplifies working with flows by allowing us to observe StateFlow directly in the UI with collectAsState, ensuring that the UI reacts to state changes automatically.

@Composable
fun MainScreen(viewModel: MainViewModel) {
val uiState by viewModel.uiState.collectAsState()

when (uiState) {
is UiState.Loading -> LoadingScreen()
is UiState.Success -> DataScreen((uiState as UiState.Success).data)
is UiState.Error -> ErrorScreen((uiState as UiState.Error).exception)
}
}

With collectAsState, we get a reactive, declarative UI that automatically updates when uiState changes.

4. Manage Lifecycle with ViewModelScope

When using Coroutines in Android, it’s crucial to avoid memory leaks by properly managing lifecycle. viewModelScope is bound to the ViewModel lifecycle, meaning that any coroutine launched in this scope will be automatically canceled when the ViewModel is cleared.

viewModelScope.launch {
// Long-running or network request
val result = fetchData()
_uiState.value = UiState.Success(result)
}

By using viewModelScope.launch, we ensure that our coroutine work is lifecycle-aware, preventing any unnecessary resource consumption.

5. Avoid UI Logic in the ViewModel

Keep UI-specific logic separate from the ViewModel to ensure the state remains clean. Avoid putting view-specific code like toasts or dialog calls in the ViewModel; instead, use events and handle them in the UI layer.

For instance, if you need to display an error message:

class MainViewModel : ViewModel() {

private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState

private val _eventFlow = MutableSharedFlow<UiEvent>()
val eventFlow: SharedFlow<UiEvent> = _eventFlow

fun loadData() {
viewModelScope.launch {
try {
val data = fetchData()
_uiState.value = UiState.Success(data)
} catch (e: Exception) {
_uiState.value = UiState.Error(e)
_eventFlow.emit(UiEvent.ShowToast("An error occurred"))
}
}
}
}

Here, ShowToast is emitted as an event, which the UI layer listens to and triggers a toast or alert dialog when necessary.

Conclusion

Using Kotlin Coroutines and Flow for state management in Android apps can significantly improve the responsiveness, readability, and efficiency of your code. By following these best practices, you can create a clean, maintainable architecture that leverages modern, reactive programming principles.

Whether you’re building new features or optimizing an existing app, managing state with StateFlow, SharedFlow and ViewModelScope can simplify your code and make it easier to handle complex UI states and asynchronous operations.

Thanks for your time

Happy coding :)

--

--

No responses yet