Skip to Content
Course content

66: Building a Task Manager with Coroutines and Flow

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I want to start today with a piece of code that looks perfectly reasonable at first glance. Imagine you're building the core logic for a Task Manager. You want a way to stream the current list of tasks to the UI so that whenever a task is added or completed, the screen updates automatically. You might write something like this:

class TaskManager {
    private val tasks = mutableListOf<Task>()

    fun getTasks(): Flow<List<Task>> = flow {
        emit(tasks.toList())
    }

    suspend fun addTask(task: Task) {
        tasks.add(task)
    }
}

The "Ghost Update" Bug

If you run this, you'll notice something frustrating: you call addTask(), the list inside the manager grows, but your UI doesn't budge. You're collecting the flow, but the updates never arrive. Why?

The issue here is that you've created a cold flow. In Kotlin, the flow { ... } builder creates a stream that only executes its code when it is collected. Every time you call getTasks().collect(), it simply emits the current state of the list and then completes. It has no mechanism to "push" new data when the tasks list changes. It's essentially a fancy way of returning a list once, rather than a living stream of data.

Switching to StateFlow for Persistent State

To fix this, we need a hot flow. Specifically, we want a StateFlow. Unlike a cold flow, a StateFlow exists independently of collectors; it holds a value in memory and broadcasts that value to anyone listening.

Here is how I would refactor this to make it actually work in a production environment:

class TaskManager {
    // We keep the mutable state private so external classes can't mess with it
    private val _tasks = MutableStateFlow<List<Task>>(emptyList())
    
    // We expose a read-only StateFlow to the outside world
    val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()

    suspend fun addTask(task: Task) {
        // Use .update { } to ensure thread-safe atomic updates
        _tasks.update { currentTasks ->
            currentTasks + task
        }
    }

    suspend fun toggleTaskCompletion(taskId: Int) {
        _tasks.update { currentTasks ->
            currentTasks.map { 
                if (it.id == taskId) it.copy(isCompleted = !it.isCompleted) else it 
            }
        }
    }
}

Notice the use of _tasks.update { ... }. I've seen a lot of developers just do _tasks.value = _tasks.value + task. In a multi-threaded environment, that's a recipe for race conditions. update ensures that you're working with the most recent state before applying your change.

Combining Multiple Streams for Filtering

A Task Manager isn't very useful if you can't filter your tasks. This is where the real power of Flow comes in. Instead of creating a separate function for "get completed tasks," we can treat the filter itself as a stream of data and combine it with our task list.

I like to think of this as a "pipeline." We have a stream of tasks and a stream of filter preferences. Whenever either one changes, the pipeline recalculates the result automatically.

class TaskViewModel(private val taskManager: TaskManager) {
    private val filterState = MutableStateFlow(Filter.ALL)

    // Combine the tasks flow and the filter flow into one result stream
    val filteredTasks: Flow<List<Task>> = combine(
        taskManager.tasks, 
        filterState
    ) { tasks, filter ->
        when (filter) {
            Filter.ALL -> tasks
            Filter.COMPLETED -> tasks.filter { it.isCompleted }
            Filter.ACTIVE -> tasks.filter { !it.isCompleted }
        }
    }

    fun setFilter(filter: Filter) {
        filterState.value = filter
    }
}

By using combine, you've removed the need to manually call a "refresh" function. If a task is marked as completed while the user is looking at the "Active" filter, that task will instantly vanish from the list because the filteredTasks flow re-emits the filtered result the moment taskManager.tasks updates.




📋 Practical Task

Exercise: Implementing a Real-Time Search Query Filter

You need to extend the current Task Manager logic to support a real-time search bar. Instead of just filtering by status (All/Active/Completed), the user should be able to type a query that filters tasks by their title.

Your Task:

  • Create a MutableStateFlow<String> to hold the current search query.
  • Modify the filteredTasks flow (or create a new one) using the combine operator to filter the task list by both the current Filter status AND the search query string.
  • Ensure the search is case-insensitive.
  • Implement a updateSearchQuery(query: String) function that updates the state flow.

Hint: You will be combining three different flows: the task list, the status filter, and the search query string.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.