In Kotlin Flow code, scope functions are useful, but they should usually play a supporting role. The main structure of your reactive pipeline should come from Flow operators such as map, filter, flatMapLatest, combine, onEach, catch, and stateIn.
A good rule of thumb:
Flow operators describe the stream.
Scope functions describe what you do with each value.
1. Use map for stream transformation, let for local value transformation
If you are transforming each emitted value, the outer operation should usually be map.
val userNames: Flow<String> =
usersFlow.map { user ->
user.let {
"${it.firstName} ${it.lastName}"
}
}
In simple cases, let may be unnecessary:
val userNames: Flow<String> =
usersFlow.map { user ->
"${user.firstName} ${user.lastName}"
}
Use let inside map when it clarifies a local transformation, especially for nullable values or multistep conversion.
val profileNames: Flow<String> =
usersFlow.map { user ->
user.profile?.let { profile ->
profile.displayName
} ?: "Anonymous"
}
2. Use onEach for stream side effects, not also as the main Flow operator
For logging, analytics, caching, or debugging, prefer onEach.
val users: Flow<List<User>> =
userRepository.users()
.onEach { users ->
logger.info("Loaded ${users.size} users")
}
Inside a transformation, also can be fine when you want to return the same value after a local side effect:
val users: Flow<List<User>> =
userRepository.users()
.map { users ->
users.filter { it.isActive }
.also { activeUsers ->
logger.debug("Active users: ${activeUsers.size}")
}
}
But avoid using also where onEach expresses the intent better:
val users: Flow<List<User>> =
userRepository.users()
.onEach { logger.debug("Received users: $it") }
.map { users -> users.filter { it.isActive } }
3. Use run when computing one result from an emitted object
run is useful when each emitted value needs a multistep computation.
val summaries: Flow<UserSummary> =
usersFlow.map { user ->
user.run {
val fullName = "$firstName $lastName"
val status = if (isActive) "active" else "inactive"
UserSummary(
id = id,
name = fullName,
status = status
)
}
}
This works well when you want receiver-style access with this.
4. Use apply when constructing objects inside a Flow
apply is useful for configuring a mutable object before emitting or returning it.
val requests: Flow<Request> =
userIds.map { userId ->
Request().apply {
method = "GET"
path = "/users/$userId"
headers["Accept"] = "application/json"
}
}
That said, in reactive code, immutable data classes are often clearer:
val requests: Flow<Request> =
userIds.map { userId ->
Request(
method = "GET",
path = "/users/$userId",
headers = mapOf("Accept" to "application/json")
)
}
Use apply mainly when an API requires mutable configuration.
5. Use with sparingly inside Flow chains
with can be useful when working with an existing object, but nested receivers can become confusing inside Flow pipelines.
val messages: Flow<String> =
events.map { event ->
with(event.metadata) {
"source=$source, timestamp=$timestamp"
}
}
This is fine if the receiver is obvious. But if you already have multiple nested lambdas, explicit names may be clearer:
val messages: Flow<String> =
events.map { event ->
val metadata = event.metadata
"source=${metadata.source}, timestamp=${metadata.timestamp}"
}
6. Be careful with nested it
Flow pipelines often contain nested lambdas. Scope functions can make that worse if every lambda uses implicit it.
Harder to read:
val result: Flow<List<String>> =
usersFlow.map {
it.filter {
it.isActive
}.map {
it.name
}
}
Clearer:
val result: Flow<List<String>> =
usersFlow.map { users ->
users.filter { user ->
user.isActive
}.map { user ->
user.name
}
}
This matters even more with scope functions:
val result: Flow<UserDto> =
usersFlow.map { user ->
user.profile?.let { profile ->
UserDto(
id = user.id,
displayName = profile.displayName
)
} ?: UserDto(
id = user.id,
displayName = "Anonymous"
)
}
Prefer named lambda parameters when combining Flow operators and scope functions.
7. Use takeIf / takeUnless with care
Although not scope functions in the same group, takeIf and takeUnless often appear with let.
For simple filtering, prefer Flow’s filter:
val activeUsers: Flow<User> =
usersFlow.filter { user ->
user.isActive
}
Instead of:
val activeUsers: Flow<User> =
usersFlow.mapNotNull { user ->
user.takeIf { it.isActive }
}
But takeIf can be useful when a transformation may produce null:
val validEmails: Flow<String> =
usersFlow.mapNotNull { user ->
user.email
?.takeIf { email -> email.contains("@") }
?.lowercase()
}
8. Use mapNotNull with let for nullable values
This is a widespread Flow pattern.
val avatars: Flow<Avatar> =
usersFlow.mapNotNull { user ->
user.avatarUrl?.let { url ->
Avatar(url)
}
}
Or:
val displayNames: Flow<String> =
usersFlow.mapNotNull { user ->
user.profile?.displayName
}
Use let when constructing a result from a nullable value is more involved.
9. Use flatMapLatest when the scope contains another Flow
If the transformation returns another Flow, do not use only let or map unless you intentionally want a nested Flow<Flow<T>>.
Usually:
val userDetails: Flow<UserDetails> =
selectedUserId
.filterNotNull()
.flatMapLatest { userId ->
userRepository.observeUserDetails(userId)
}
If the ID is nullable, and you need fallback behavior:
val userDetails: Flow<UserDetails?> =
selectedUserId.flatMapLatest { userId ->
userId?.let {
userRepository.observeUserDetails(it)
} ?: flowOf(null)
}
Here, let is handling the nullable value, while flatMapLatest handles the reactive flattening.
10. Prefer Flow operators for lifecycle and errors
Use catch, onStart, onCompletion, and retry rather than trying to encode those behaviors with scope functions.
val uiState: Flow<UiState> =
userRepository.users()
.map { users ->
UiState.Success(users)
}
.onStart {
emit(UiState.Loading)
}
.catch { throwable ->
emit(UiState.Error(throwable.message ?: "Unknown error"))
}
Scope functions can still help locally:
val uiState: Flow<UiState> =
userRepository.users()
.map { users ->
users
.filter { user -> user.isActive }
.let { activeUsers -> UiState.Success(activeUsers) }
}
.onStart {
emit(UiState.Loading)
}
.catch { throwable ->
emit(UiState.Error(throwable.message ?: "Unknown error"))
}
Practical mapping
| Intent in Flow code | Prefer | Scope function role |
|---|---|---|
| Transform each emission | map |
Use let/run inside if helpful |
| Remove nulls | filterNotNull, mapNotNull |
Use let for nullable conversion |
| Side effect per emission | onEach |
Use also only locally |
| Build/configure object | map + constructor or apply |
apply for mutable setup |
| Switch to another Flow | flatMapLatest, flatMapConcat, flatMapMerge |
Use let for nullable branch |
| Combine streams | combine, zip |
Scope functions only inside result builder |
| Handle errors | catch, retry |
Scope functions rarely needed |
| Emit loading state | onStart |
Scope functions rarely needed |
Example: realistic UI state pipeline
val uiState: StateFlow<UserUiState> =
selectedUserId
.filterNotNull()
.flatMapLatest { userId ->
userRepository.observeUser(userId)
}
.map { user ->
user.run {
UserUiState.Content(
id = id,
title = "$firstName $lastName",
subtitle = email ?: "No email"
)
}
}
.onEach { state ->
analytics.logScreenState(state)
}
.catch { throwable ->
emit(UserUiState.Error(throwable.message ?: "Unable to load user"))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserUiState.Loading
)
Here:
filterNotNullhandles nullable IDs.flatMapLatestswitches to the latest selected user stream.runcomputes a UI model from aUser.onEachperforms a side effect.catchhandles errors.stateInturns the cold flow into aStateFlow.
Main guideline
Use scope functions in Flow pipelines when they improve the readability of local value handling.
Avoid using them to replace Flow operators.
Good:
Flow operators for stream behavior.
Scope functions for per-value clarity.
Risky:
Long chains of map/let/also/run with nested it everywhere.
If the chain starts becoming hard to read, introduce named lambda parameters or local variables.
