skip to content

Show how onStart and onCompletion are used to drive a loading/idle UI state, and why this is preferred over try/finally around collect.

level: middleimportance: should knowfreq 55%

answer

  1. onStart => Loading state
  2. onCompletion => clears spinner always
  3. catch after onStart, before terminal
  4. Operator chain beats try/finally
  5. Runs in collector context

basics

~10 s

Use onStart to switch the UI to loading before data arrives, and onCompletion to switch it back to done after the flow ends. They keep show/hide symmetric and tied to that one flow.

solid answer

~40 s

A canonical pattern: `repository.stream().onStart { _state.value = Loading }.onCompletion { _state.value = Idle }.catch { _state.value = Error(it) }.collect { _state.value = Data(it) }`. onStart fires before the first emission, so the spinner appears immediately; onCompletion fires for normal/cancel/failure, guaranteeing the spinner is cleared even on error or cancellation. Because onCompletion observes-but-rethrows, you add catch to actually handle errors. This beats wrapping collect in try/finally because the lifecycle logic attaches to the specific flow, runs in the collector context, composes with other operators, and reads top-to-bottom in the chain. It also lets you emit() a placeholder in onStart (e.g. emit(emptyList())) if your downstream consumes flow values rather than mutable state.

code

kotlin · 6 lines
kotlin
repository.itemsFlow()
    .onStart { _loading.value = true }
    .onCompletion { _loading.value = false }   // runs on success, error, cancel
    .catch { e -> _error.value = e.message }
    .onEach { _items.value = it }
    .launchIn(viewModelScope)

go deeper

for a junior

Wires onStart to a Loading state and onCompletion to clear it.

for a middle

Adds correct catch placement and explains why onCompletion covers cancellation/error paths.

for a senior

Articulates locality, composition, and context advantages over try/finally and the seed-emit capability.

for a principal

Designs a reusable lifecycle operator and reasons about operator ordering, context preservation, and exception transparency across the chain.

## The problem UI code repeatedly needs: *turn on a spinner before loading, turn it off when done regardless of outcome, and handle errors.* Flow's lifecycle operators express exactly this. ## The idiomatic chain ```kotlin sealed interface UiState { data object Loading : UiState data class Data(val items: List<Item>) : UiState data class Error(val message: String) : UiState } fun load() { repository.itemsFlow() .onStart { _state.value = UiState.Loading } // before first emission .onCompletion { /* optional: stop spinner if separate */ } .catch { e -> _state.value = UiState.Error(e.message ?: "error") } .onEach { items -> _state.value = UiState.Data(items) } .launchIn(viewModelScope) } ``` - `onStart` flips to `Loading` **once, before** any data, so the UI shows a spinner immediately. - `catch` (placed **after** onStart, before the terminal) turns failures into an `Error` state and stops propagation. - `onCompletion` would clear a *separate* progress flag for **every** ending (success, error, cancel). ## Why not try/finally? ```kotlin // Less idiomatic try { showSpinner() flow.collect { render(it) } } catch (e: Exception) { showError(e) } finally { hideSpinner() } ``` This works, but the operator form is preferred because: - **Locality**: the lifecycle hooks attach to *that flow*, so the same flow can be reused/launched elsewhere carrying its own behavior. - **Composition**: you can stack `retry`, `catch`, `flowOn`, etc., in a single readable pipeline. - **Context correctness**: onStart/onCompletion run in the collector context; mixing `flowOn` upstream won't shift them. - **Emit capability**: onStart can `emit()` a seed value, which a try/finally cannot inject into the stream. ## Ordering matters Put `catch` **before** the terminal so it handles upstream failures, and put `onStart` **before** `catch` so the spinner shows even if the producer fails instantly. Place `onCompletion` where it should observe the final cause (typically near the end). A misplaced `catch` (after onCompletion) makes onCompletion still see the failure cause. ## Cancellation safety Because onCompletion runs on cancellation too, the spinner is cleared if the user navigates away and the scope is cancelled — something a naive `onEach`-only approach would miss.

  • Where must catch go relative to onStart and the terminal operator?
    After onStart (so the spinner shows even on an immediate producer failure) and before the terminal (so it can intercept upstream errors).
  • Why does onCompletion clear the spinner even when the user navigates away?
    Scope cancellation throws CancellationException, which onCompletion observes as a completion cause, so its cleanup still runs.

It's a turnstile: onStart opens the gate (spinner on), onCompletion closes it whether the visitor walked through, left early, or tripped.

saying these in an interview costs you the question

  • Clearing the spinner only in onEach, missing error/cancel paths
  • Putting catch after onCompletion and expecting clean completion
  • Saying try/finally and the operators are functionally identical in all respects
  • Forgetting onStart can emit a seed value
  • Not handling errors at all because onCompletion 'covers it'

context