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.
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.
On this page
StateFlowLiveDataMutableStateFlowStateFlowThe most important StateFlow pattern: represent all UI states explicitly:
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:
@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
collectAsStateWithLifecycle()collectAsState()When your UI depends on multiple data sources:
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
}
}SharingStarted.WhileSubscribed(5_000)Update the UI immediately, then sync to the backend. Roll back on failure:
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"))
}
}
}
}State persists and replays. Some things — navigation, snackbars, toasts — are one-time events that shouldn't replay on recomposition:
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:
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
}
}debounce(300)flatMapLatestSometimes you need loading state per action, not just for initial load:
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"
)
}
}
}
}
}_uiState.update {}combine()SharingStarted.WhileSubscribed(5_000)SharedFlow_uiState.update {}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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read