---
title: "skill by agenticluke · skilld"
canonical_url: "https://skilld.dev/gh/agenticluke/kotlin-flow-guide-plus"
meta:
  description: "Use Kotlin coroutines and Flow in Android and KMP. Covers safe scopes, parallel work, StateFlow, SharedFlow, errors, cancel rules, and tests. From agenticluke/kotlin-flow-guide-plus."
  "og:description": "Use Kotlin coroutines and Flow in Android and KMP. Covers safe scopes, parallel work, StateFlow, SharedFlow, errors, cancel rules, and tests. From agenticluke/kotlin-flow-guide-plus."
  "og:title": "skill by agenticluke"
  "twitter:description": "Use Kotlin coroutines and Flow in Android and KMP. Covers safe scopes, parallel work, StateFlow, SharedFlow, errors, cancel rules, and tests. From agenticluke/kotlin-flow-guide-plus."
  "twitter:title": "skill by agenticluke"
---

`

[All skills](https://skilld.dev/skills)

[![agenticluke avatar](https://skilld.dev/_img/avatar?url=https%3A%2F%2Fgithub.com%2Fagenticluke.png%3Fsize%3D96)](https://skilld.dev/gh/agenticluke)

# **/skill**

[@1023c56](https://github.com/agenticluke/kotlin-flow-guide-plus/commit/1023c565530ecd75b32eb77974ce6dd61ab4d710 "Your agent reads SKILL.md at commit 1023c56")

by [agenticluke](https://skilld.dev/gh/agenticluke)· [agenticluke](https://skilld.dev/gh/agenticluke)/ [kotlin-flow-guide-plus](https://skilld.dev/gh/agenticluke/kotlin-flow-guide-plus)

Use Kotlin coroutines and Flow in Android and KMP. Covers safe scopes, parallel work, StateFlow, SharedFlow, errors, cancel rules, and tests.

- 1 file
- 13.8 KB
- Updated 2 weeks ago
- [GitHub](https://github.com/agenticluke/kotlin-flow-guide-plus/blob/main/skill/SKILL.md "View SKILL.md on GitHub")

## SKILL.md

13.8 KB

**≈37** tokens always: the name and description. **≈3.5k** when used: this file.

## Kotlin Coroutines and Flow

Use these patterns for async work and data streams in Android and Kotlin Multiplatform projects.

### Use This Skill When

- Writing async Kotlin code
- Using `Flow`, `StateFlow`, or `SharedFlow`
- Loading data at the same time
- Adding debounce or retry logic
- Managing scope and cancel rules
- Testing coroutines and flows

### Core Rules

- Use a scope tied to a clear owner.
- Do not use `GlobalScope`.
- Let cancel errors pass through.
- Keep state values read-only outside their owner.
- Use immutable state and list copies.
- Pass dispatchers into code that needs easy tests.
- Collect UI flows only while the UI is active.

### Structured Concurrency

A parent scope owns its child jobs:

```
Application
  └── viewModelScope
        └── coroutineScope
              ├── async
              └── async
```

Use a scope with a known life:

```
// Bad: no clear owner
GlobalScope.launch {
    fetchData()
}

// Good: stops when the ViewModel is cleared
viewModelScope.launch {
    fetchData()
}

// Good: stops when this Compose effect leaves the UI
LaunchedEffect(key) {
    fetchData()
}
```

Do not create a new `CoroutineScope` unless the code owns it and can cancel it.

#### Run Work at the Same Time

Use `coroutineScope` with `async` when all results are needed:

```
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val items = async { itemRepository.getRecent() }
    val stats = async { statsRepository.getToday() }
    val profile = async { userRepository.getCurrent() }

    Dashboard(
        items = items.await(),
        stats = stats.await(),
        profile = profile.await()
    )
}
```

If one child fails, the other children are canceled. The error is sent to the caller.

Do not use `async` for work whose result is never read. Use `launch` for that work.

#### Keep Other Children Running

Use `supervisorScope` when one child may fail without stopping its siblings:

```
suspend fun syncAll() = supervisorScope {
    val items = async { runCatching { syncItems() } }
    val stats = async { runCatching { syncStats() } }
    val settings = async { runCatching { syncSettings() } }

    listOf(
        items.await(),
        stats.await(),
        settings.await()
    )
}
```

Handle each error. A failed `launch` can still reach the app error handler if no code catches it.

### Flow Patterns

#### Cold Flow

A cold flow starts again for each collector.

If a data source already returns a flow, map it directly:

```
fun observeItems(): Flow<List<Item>> =
    itemDao.observeAll()
        .map { rows -> rows.map { it.toDomain() } }
```

Use `flow {}` when you need to turn suspend work into a flow:

```
fun loadItem(id: String): Flow<Item> = flow {
    emit(api.getItem(id))
}
```

Do not wrap an existing flow in `flow { collect { emit(it) } }` unless extra control is needed.

#### StateFlow for UI State

Keep mutable state private:

```
class DashboardViewModel(
    observeProgress: ObserveUserProgressUseCase
) : ViewModel() {
    val progress: StateFlow<UserProgress> = observeProgress()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UserProgress.EMPTY
        )
}
```

`WhileSubscribed(5_000)` keeps the source active for five seconds after the last collector leaves. This can avoid a restart during a screen change.

Pick the start rule on purpose:

- Use `WhileSubscribed` for UI state.
- Use `Lazily` when work should start on the first collector.
- Use `Eagerly` only when work must start at once.

The first value is `initialValue`. Make sure it is safe to show.

#### Join More Than One Flow

Use `combine` when any input change should make new state:

```
val uiState: StateFlow<HomeState> = combine(
    itemRepository.observeItems(),
    settingsRepository.observeTheme(),
    userRepository.observeProfile()
) { items, theme, profile ->
    HomeState(
        items = items,
        theme = theme,
        profile = profile
    )
}.stateIn(
    scope = viewModelScope,
    started = SharingStarted.WhileSubscribed(5_000),
    initialValue = HomeState()
)
```

`combine` waits until each input has sent at least one value.

#### Search with Debounce

Use `flatMapLatest` when a new query should stop the old search:

```
searchQuery
    .debounce(300)
    .map(String::trim)
    .distinctUntilChanged()
    .flatMapLatest { query ->
        if (query.isBlank()) {
            flowOf(emptyList())
        } else {
            repository.search(query)
        }
    }
    .catch { error ->
        if (error is CancellationException) throw error
        emit(emptyList())
    }
    .collect { results ->
        _state.update { it.copy(results = results) }
    }
```

The `catch` operator handles errors from code above it. It does not catch errors thrown by the collector below it.

#### Retry with a Limit

Retry only errors that may pass after a short wait:

```
fun fetchWithRetry(): Flow<Data> =
    flow {
        emit(api.fetch())
    }.retryWhen { cause, attempt ->
        if (cause is IOException && attempt < 3) {
            val waitMs = 1_000L * (1L shl attempt.toInt())
            delay(waitMs)
            true
        } else {
            false
        }
    }
```

This makes up to four calls: the first call and three retries.

Do not retry login errors, bad input, or other errors that need user action.

#### SharedFlow for Effects

Use a `SharedFlow` for effects that may be seen by active collectors:

```
class ItemListViewModel : ViewModel() {
    private val _effects = MutableSharedFlow<Effect>(
        extraBufferCapacity = 1
    )
    val effects: SharedFlow<Effect> = _effects.asSharedFlow()

    sealed interface Effect {
        data class ShowSnackbar(val message: String) : Effect
        data class NavigateTo(val route: String) : Effect
    }

    fun deleteItem(id: String) {
        viewModelScope.launch {
            repository.delete(id)
            _effects.emit(Effect.ShowSnackbar("Item deleted"))
        }
    }
}
```

Collect it in Compose:

```
LaunchedEffect(viewModel) {
    viewModel.effects.collect { effect ->
        when (effect) {
            is Effect.ShowSnackbar ->
                snackbarHostState.showSnackbar(effect.message)

            is Effect.NavigateTo ->
                navController.navigate(effect.route)
        }
    }
}
```

A `SharedFlow` with `replay = 0` does not save an effect for a later collector. If an effect must not be lost, store it in UI state or use a `Channel` with one clear receiver.

Do not use `StateFlow` for one-time events. A new collector may see the old event again.

### Dispatchers

Use a dispatcher that fits the work:

```
// Heavy CPU work
withContext(Dispatchers.Default) {
    parseJson(largePayload)
}

// Blocking file, network, or database work on Android or JVM
withContext(Dispatchers.IO) {
    database.query()
}

// UI work
withContext(Dispatchers.Main) {
    updateUi()
}
```

Many modern database and network tools already move blocking work off the main thread. Check the tool before adding `withContext`.

For KMP, do not assume `Dispatchers.IO` works the same on every target. Pass a dispatcher into shared code:

```
class ItemRepository(
    private val ioDispatcher: CoroutineDispatcher
) {
    suspend fun load(): List<Item> =
        withContext(ioDispatcher) {
            readItems()
        }
}
```

This also makes tests easy.

### Cancellation

#### Check Long Loops

Cancel works at suspend points. A long loop with no suspend call must check for cancel:

```
suspend fun processItems(items: List<Item>) {
    for (item in items) {
        currentCoroutineContext().ensureActive()
        process(item)
    }
}
```

#### Do Not Hide Cancellation

If code catches a wide error type, send cancel errors back up:

```
try {
    repository.fetch()
} catch (error: CancellationException) {
    throw error
} catch (error: Exception) {
    showError(error)
}
```

#### Clean Up with `finally`

```
viewModelScope.launch {
    try {
        _state.update { it.copy(isLoading = true) }
        val data = repository.fetch()
        _state.update { it.copy(data = data) }
    } finally {
        _state.update { it.copy(isLoading = false) }
    }
}
```

A `finally` block runs during cancel. Do not start long suspend work there.

If cleanup must suspend, use `NonCancellable` only for short, vital cleanup:

```
finally {
    withContext(NonCancellable) {
        closeSession()
    }
}
```

### Collect in Android UI

In a Fragment or Activity, collect only while the UI is started:

```
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}
```

In Compose, prefer lifecycle-aware state collection when it is available:

```
val state by viewModel.uiState.collectAsStateWithLifecycle()
```

Do not start a new collector on every screen update.

### Tests

Use `runTest`. Pass test dispatchers into the code under test. Do not use real delays.

#### Test a Flow with Turbine

```
@Test
fun `search updates item list`() = runTest {
    val repository = FakeItemRepository()
    val viewModel = ItemListViewModel(repository)

    viewModel.state.test {
        assertEquals(ItemListState(), awaitItem())

        viewModel.onSearch("cats")
        assertTrue(awaitItem().isLoading)

        repository.emit(
            listOf(Item(id = "1", name = "Cat"))
        )

        val loaded = awaitItem()
        assertFalse(loaded.isLoading)
        assertEquals(1, loaded.items.size)

        cancelAndIgnoreRemainingEvents()
    }
}
```

End the Turbine test after the needed checks. A hot flow does not end on its own.

#### Test Work That Runs in the Background

```
@Test
fun `parallel load fills the state`() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val viewModel = DashboardViewModel(
        itemRepo = FakeItemRepo(),
        statsRepo = FakeStatsRepo(),
        dispatcher = dispatcher
    )

    viewModel.load()
    advanceUntilIdle()

    assertNotNull(viewModel.state.value.items)
    assertNotNull(viewModel.state.value.stats)
}
```

If a ViewModel uses `Dispatchers.Main`, set a test main dispatcher before the test and reset it after the test.

#### Fake a Flow Source

```
class FakeItemRepository : ItemRepository {
    private val items =
        MutableStateFlow<List<Item>>(emptyList())

    override fun observeItems(): Flow<List<Item>> = items

    fun emit(value: List<Item>) {
        items.value = value.toList()
    }

    override suspend fun getItemsByCategory(
        category: String
    ): Result<List<Item>> {
        return Result.success(
            items.value.filter { it.category == category }
        )
    }
}
```

Copy input lists so later test changes do not change old state by mistake.

### Full Usage Example

This ViewModel loads items from a search flow and keeps errors in UI state:

```
data class SearchState(
    val query: String = "",
    val items: List<Item> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

class SearchViewModel(
    private val repository: ItemRepository
) : ViewModel() {
    private val query = MutableStateFlow("")

    val state: StateFlow<SearchState> = query
        .debounce(300)
        .map(String::trim)
        .distinctUntilChanged()
        .flatMapLatest { text ->
            if (text.isBlank()) {
                flowOf(SearchState(query = text))
            } else {
                repository.search(text)
                    .map<List<Item>, SearchState> { items ->
                        SearchState(
                            query = text,
                            items = items
                        )
                    }
                    .onStart {
                        emit(
                            SearchState(
                                query = text,
                                isLoading = true
                            )
                        )
                    }
                    .catch { error ->
                        if (error is CancellationException) throw error
                        emit(
                            SearchState(
                                query = text,
                                error = error.message ?: "Search failed"
                            )
                        )
                    }
            }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = SearchState()
        )

    fun onQueryChange(value: String) {
        query.value = value
    }
}
```

A new query stops the old search. A blank query clears the results. Cancel errors still pass through.

### Avoid These Mistakes

- Do not use `GlobalScope`.
- Do not make a scope that no owner can cancel.
- Do not catch and hide `CancellationException`.
- Do not change a mutable list inside `StateFlow`.
- Do not expose `MutableStateFlow` outside its owner.
- Do not use `flowOn(Dispatchers.Main)` to choose where collection runs.
- Do not put UI work above `flowOn`.
- Do not make a new flow during each Compose update without `remember`.
- Do not use `runBlocking` in app code.
- Do not use real time or real dispatchers in unit tests.
- Do not collect the same cold flow many times if the work should be shared.
- Do not assume a `SharedFlow` saves events for users who are not listening.
- Do not update Android views from a worker dispatcher.

### Related Skills

- Use `compose-multiplatform-patterns` for Flow use in Compose UI.
- Use `android-clean-architecture` for coroutine rules across app layers.

Source: [SKILL.md on GitHub](https://github.com/agenticluke/kotlin-flow-guide-plus/blob/main/skill/SKILL.md)

## Third-party checks

No third-party reports yet.

## Provenance

[Signed by skilld at 1023c56.](https://github.com/agenticluke/kotlin-flow-guide-plus/commit/1023c565530ecd75b32eb77974ce6dd61ab4d710 "1023c565530ecd75b32eb77974ce6dd61ab4d710") This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago

## Capability

<dl>

<dt>origin</dt>
<dd>ECC</dd>

</dl>

## README badge

![README badge for agenticluke/kotlin-flow-guide-plus](https://skilld.dev/b/agenticluke/kotlin-flow-guide-plus?theme=light&label=0)

## Related skills

-
-
-
-
-
-