Kotlin Coroutines & Flow
Coroutines and Flow primitives for Compose apps: StateFlow, SharedFlow, Channel, operators, dispatchers, scopes, and exception handling. Works on all CMP targets.
References:
StateFlow vs SharedFlow vs Channel
|
StateFlow |
SharedFlow |
Channel |
| Holds current value |
Yes (replay=1, conflated) |
No (configurable replay) |
No |
| New collector gets |
Latest value immediately |
Replayed values (if configured) |
Nothing (consumed) |
| Delivery |
All collectors |
All collectors |
One receiver |
| Duplicate filtering |
distinctUntilChanged built-in |
None |
None |
| Use for |
UI state |
Broadcasting events |
One-off effects |
MVI mapping
When to use which
- Screen state (loading, data, errors, form input) →
StateFlow
- One-off UI effects (navigate, snackbar, haptic) →
Channel(BUFFERED) collected via CollectEffect
- Broadcasting to multiple collectors (analytics, logging) →
SharedFlow with appropriate replay
- Hot data streams (search results reacting to query) → cold
Flow converted via stateIn
Common mistakes
- StateFlow for one-off events → shows twice on config change (new collector gets latest)
SharedFlow(replay=0) for mandatory effects → lost when UI detached
Channel() default (RENDEZVOUS) → suspends sender if no receiver; use Channel.BUFFERED
Flow Operators Quick Reference
Transforming
| Operator |
Purpose |
map { } |
Transform each value |
mapNotNull { } |
Transform and drop nulls |
filter { } |
Keep values matching predicate |
take(n) / drop(n) |
Take first n / skip first n |
Flattening
| Operator |
Behavior |
Use when |
flatMapLatest { } |
Cancel previous inner flow |
Search queries — only latest |
flatMapConcat { } |
Sequential, wait for completion |
Order matters |
flatMapMerge { } |
Concurrent inner flows |
Parallel, order irrelevant |
Combining
| Operator |
Behavior |
Use when |
combine(flowA, flowB) { a, b -> } |
Emit when ANY emits, latest from each |
Multiple independent state sources |
zip(flowA, flowB) { a, b -> } |
Paired emissions only |
Synchronized pairs |
merge(flowA, flowB) |
Interleave emissions |
Unified event stream |
Gotcha: combine waits until every upstream emits at least once before producing output.
Timing / Error / Side effects
| Operator |
Purpose |
debounce(300) |
Wait for pause (search input) |
sample(1000) |
Latest at fixed intervals |
distinctUntilChanged() |
Skip consecutive duplicates |
catch { } |
Handle upstream errors, can emit() fallback |
retry(3) / retryWhen { cause, attempt -> } |
Retry with optional backoff |
onEach { } / onStart { } / onCompletion { } |
Side effects |
Terminal operators
| Operator |
Purpose |
collect { } / collectLatest { } |
Collect values (suspends) |
first() / toList() |
Single value / all values |
launchIn(scope) |
Start collection in scope |
stateIn(scope) / shareIn(scope) |
Convert to hot StateFlow/SharedFlow |
Dispatchers
| Dispatcher |
Use for |
CMP support |
Dispatchers.Main |
UI state updates, composable callbacks |
All targets |
Dispatchers.IO |
Network, database, file I/O |
All targets (since 1.7+) |
Dispatchers.Default |
CPU-heavy computation, sorting, parsing |
All targets |
Main-safe rule: the callee switches dispatchers, not the caller:
Inject dispatchers as constructor params for testability.
Structured Concurrency and Scopes
| Scope |
Lifecycle |
Use for |
viewModelScope |
ViewModel cleared |
ViewModel coroutines (CMP commonMain since lifecycle 2.8+) |
lifecycleScope |
Lifecycle destroyed |
Android Activity/Fragment only |
rememberCoroutineScope() |
Leaves composition |
Compose event handlers |
coroutineScope { } |
All children complete |
Parallel decomposition (one fails → all cancel) |
supervisorScope { } |
Child failure independent |
Independent parallel tasks |
Use supervisorScope when tasks are independent (dashboard sections). Use coroutineScope when all must succeed together. Never use GlobalScope — no lifecycle, memory leak. Never create unbound CoroutineScope(Job()) without lifecycle management.
Exception Handling
launch vs async
launch: exception propagates immediately. async: exception deferred until await().
CancellationException — never swallow
stateIn and shareIn
Convert cold Flow to hot StateFlow/SharedFlow. Always declare as val, never per function call.
| Strategy |
Starts |
Stops |
Use for |
WhileSubscribed(5000) |
First collector |
5s after last gone |
ViewModel state — stops upstream when UI gone |
Lazily |
First collector |
Never (scope cancel) |
Expensive-to-restart shared resources |
Eagerly |
Immediately |
Never (scope cancel) |
Data needed before first collector |
Anti-Patterns
| Anti-pattern |
Why it hurts |
Fix |
GlobalScope.launch { } |
No lifecycle, memory leak |
viewModelScope or structured scope |
runBlocking on Main |
Blocks UI, ANR |
launch / async from coroutine scope |
Swallowing CancellationException |
Zombie coroutines |
Always rethrow |
Blocking I/O on Dispatchers.Default |
Starves CPU pool |
Dispatchers.IO |
Non-suspending loop without ensureActive() |
Ignores cancellation |
Check isActive / ensureActive() |
stateIn per function call |
Leaks hot flows |
Declare as val, create once |
catch (e: Throwable) |
Catches everything including OOM |
catch (e: Exception) + rethrow CancellationException |
Hardcoded Dispatchers.IO |
Untestable |
Inject dispatcher as constructor param |
combine without initial values |
No output until all emit |
onStart { emit(default) } |
Advanced Patterns
For backpressure, callbackFlow/channelFlow, Mutex/Semaphore, and Turbine testing, see coroutines-flow-advanced.md.