Skip to content
All posts
March 27, 20264 min read

Advanced StateFlow Patterns for Android ViewModels

Basic StateFlow usage is straightforward. The advanced patterns — combining flows, handling loading and error states, optimistic updates, and flow transformation — are where most devs hit walls. Here's how to do them correctly.

KotlinAndroidCoroutines
Share:

code
StateFlow
replacing
code
LiveData
is the standard recommendation. But knowing that
code
MutableStateFlow
holds state and
code
StateFlow
exposes it is just the beginning. Here are the patterns you'll hit in real apps.


Pattern 1: Sealed Class UI State

The most important StateFlow pattern: represent all UI states explicitly:

kotlin
sealed class TaskUiState {
    object Loading : TaskUiState()
    data class Success(val tasks: List<Task>) : TaskUiState()
    data class Error(val message: String) : TaskUiState()
    object Empty : TaskUiState()
}

class TaskViewModel(private val repository: TaskRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<TaskUiState>(TaskUiState.Loading)
    val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
}

In Compose:

kotlin
@Composable
fun TaskListScreen(viewModel: TaskViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    when (val state = uiState) {
        is TaskUiState.Loading -> LoadingIndicator()
        is TaskUiState.Success -> TaskList(tasks = state.tasks)
        is TaskUiState.Error -> ErrorView(message = state.message, onRetry = viewModel::retry)
        is TaskUiState.Empty -> EmptyState()
    }
}

Using

code
collectAsStateWithLifecycle()
instead of
code
collectAsState()
is important — it stops collection when the composable is off-screen, preventing work for invisible UI.


Pattern 2: Combining Multiple Flows

When your UI depends on multiple data sources:

kotlin
class TaskViewModel(
    private val taskRepository: TaskRepository,
    private val filterRepository: FilterRepository
) : ViewModel() {
    
    private val _filter = MutableStateFlow(TaskFilter.ALL)
    
    val uiState: StateFlow<TaskUiState> = combine(
        taskRepository.observeTasks(),
        _filter
    ) { tasks, filter ->
        val filtered = when (filter) {
            TaskFilter.ALL -> tasks
            TaskFilter.ACTIVE -> tasks.filter { !it.completed }
            TaskFilter.COMPLETED -> tasks.filter { it.completed }
        }
        if (filtered.isEmpty()) TaskUiState.Empty
        else TaskUiState.Success(filtered)
    }
    .catch { e -> emit(TaskUiState.Error(e.message ?: "Error")) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = TaskUiState.Loading
    )
    
    fun setFilter(filter: TaskFilter) {
        _filter.value = filter
    }
}

code
SharingStarted.WhileSubscribed(5_000)
keeps the flow active for 5 seconds after the last subscriber (collector) leaves — prevents re-loading data on configuration changes.


Pattern 3: Optimistic Updates

Update the UI immediately, then sync to the backend. Roll back on failure:

kotlin
class TaskViewModel(
    private val repository: TaskRepository
) : ViewModel() {
    
    private val _tasks = MutableStateFlow<List<Task>>(emptyList())
    val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()
    
    fun completeTask(taskId: String) {
        // Optimistic update — show completion immediately
        val previousTasks = _tasks.value
        _tasks.value = previousTasks.map { task ->
            if (task.id == taskId) task.copy(completed = true) else task
        }
        
        viewModelScope.launch {
            val result = repository.completeTask(taskId)
            if (result.isFailure) {
                // Rollback on failure
                _tasks.value = previousTasks
                _events.emit(UiEvent.ShowError("Failed to complete task"))
            }
        }
    }
}

Pattern 4: One-Time Events (UiEvent / SharedFlow)

State persists and replays. Some things — navigation, snackbars, toasts — are one-time events that shouldn't replay on recomposition:

kotlin
sealed class UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent()
    data class NavigateToDetail(val taskId: String) : UiEvent()
    object ShowDeleteConfirmation : UiEvent()
}

class TaskViewModel : ViewModel() {
    // SharedFlow for events — doesn't replay to new collectors
    private val _events = MutableSharedFlow<UiEvent>()
    val events: SharedFlow<UiEvent> = _events.asSharedFlow()
    
    fun deleteTask(taskId: String) {
        viewModelScope.launch {
            _events.emit(UiEvent.ShowDeleteConfirmation)
        }
    }
}

// In Composable
LaunchedEffect(viewModel.events) {
    viewModel.events.collect { event ->
        when (event) {
            is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
            is UiEvent.NavigateToDetail -> navController.navigate("tasks/${event.taskId}")
            UiEvent.ShowDeleteConfirmation -> showDeleteDialog = true
        }
    }
}

Debouncing search input to avoid triggering queries on every keystroke:

kotlin
class SearchViewModel(private val repository: TaskRepository) : ViewModel() {
    private val _searchQuery = MutableStateFlow("")
    
    val searchResults: StateFlow<List<Task>> = _searchQuery
        .debounce(300) // Wait 300ms after typing stops
        .distinctUntilChanged()
        .flatMapLatest { query ->
            if (query.isBlank()) repository.observeTasks()
            else repository.searchTasks(query)
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList()
        )
    
    fun onQueryChanged(query: String) {
        _searchQuery.value = query
    }
}

code
debounce(300)
waits for 300ms of no changes before emitting.
code
flatMapLatest
cancels the previous search when a new query arrives.


Pattern 6: Loading State for Individual Operations

Sometimes you need loading state per action, not just for initial load:

kotlin
data class TaskUiState(
    val tasks: List<Task> = emptyList(),
    val isLoading: Boolean = false,
    val isDeletingTask: String? = null, // ID of task being deleted
    val errorMessage: String? = null
)

class TaskViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(TaskUiState())
    val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
    
    fun deleteTask(taskId: String) {
        viewModelScope.launch {
            _uiState.update { it.copy(isDeletingTask = taskId) }
            
            val result = repository.deleteTask(taskId)
            
            _uiState.update { state ->
                if (result.isSuccess) {
                    state.copy(
                        isDeletingTask = null,
                        tasks = state.tasks.filter { it.id != taskId }
                    )
                } else {
                    state.copy(
                        isDeletingTask = null,
                        errorMessage = "Failed to delete task"
                    )
                }
            }
        }
    }
}

code
_uiState.update {}
is atomic — safe for concurrent updates from multiple coroutines.


Takeaways

  • Sealed class UI states (Loading/Success/Error/Empty) handle all states explicitly without null checks
  • code
    combine()
    merges multiple flows into one derived state — cleaner than managing them separately
  • code
    SharingStarted.WhileSubscribed(5_000)
    prevents re-loading on config changes
  • code
    SharedFlow
    for one-time events — navigation and snackbars shouldn't replay
  • code
    _uiState.update {}
    is atomic — prefer it over direct value assignment in concurrent scenarios
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