Interview QuestionPractical QuestionFollow-up Questions

stateIn, shareIn, and SharingStarted.WhileSubscribed

skydovesJaewoong Eum (skydoves)||15 min read

stateIn, shareIn, and SharingStarted.WhileSubscribed

Kotlin Flow exposes two operators that turn a cold flow into a hot flow shared inside a coroutine scope: shareIn produces a SharedFlow, and stateIn produces a StateFlow. Both take a SharingStarted argument that decides when the upstream is collected and when it is allowed to stop. The most common configuration on Android, SharingStarted.WhileSubscribed(5_000), looks like a magic number, but the five seconds is doing concrete work tied to configuration changes and the lifetime of a ViewModel. By the end of this lesson, you will be able to:

  • Describe what shareIn and stateIn actually do to the upstream flow.
  • Explain the three sharing commands a SharingStarted strategy can emit.
  • Trace how WhileSubscribed is built on top of subscriptionCount and transformLatest.
  • Explain why stopTimeoutMillis and replayExpirationMillis are two separate parameters.
  • Choose between Eagerly, Lazily, and WhileSubscribed for a given scope.

What stateIn and shareIn actually do

Both operators take a cold upstream flow, launch a single coroutine in the supplied scope to collect it, and forward the values into a hot MutableSharedFlow or MutableStateFlow that downstream subscribers observe. Multiple subscribers share the same upstream collection rather than each triggering an independent one.

Looking at the shareIn implementation in Share.kt:

public fun <T> Flow<T>.shareIn(
    scope: CoroutineScope,
    started: SharingStarted,
    replay: Int = 0
): SharedFlow<T> {
    val config = configureSharing(replay)
    val shared = MutableSharedFlow<T>(
        replay = replay,
        extraBufferCapacity = config.extraBufferCapacity,
        onBufferOverflow = config.onBufferOverflow
    )
    val job = scope.launchSharing(config.context, config.upstream, shared, started, NO_VALUE as T)
    return ReadonlySharedFlow(shared, job)
}

Three things happen here. A MutableSharedFlow is created as the hot relay. A sharing coroutine is launched in scope to drive collection of the upstream. The returned ReadonlySharedFlow wraps the relay and holds a strong reference to the sharing Job so the relay cannot be garbage collected while sharing is active.

stateIn is structurally identical except it creates a MutableStateFlow(initialValue) and passes initialValue (instead of NO_VALUE) into launchSharing. The initial value matters not only for the first read but also for reset semantics later.

The sharing coroutine itself dispatches on the started strategy. The relevant block in launchSharing is the one that runs when started is neither Eagerly nor Lazily:

started.command(shared.subscriptionCount)
    .distinctUntilChanged()
    .collectLatest {
        when (it) {
            SharingCommand.START -> upstream.collect(shared)
            SharingCommand.STOP -> { /* just cancel and do nothing else */ }
            SharingCommand.STOP_AND_RESET_REPLAY_CACHE -> {
                if (initialValue === NO_VALUE) {
                    shared.resetReplayCache()
                } else {
                    shared.tryEmit(initialValue)
                }
            }
        }
    }

The strategy converts the live subscriptionCount of the relay into a flow of SharingCommand values. collectLatest is the key piece: when a new command arrives, the work spawned by the previous command is cancelled. That is how a STOP cancels an ongoing upstream.collect(shared), and how a quick re-subscription can cancel a pending stop.

Notice the reset branch. When initialValue is the sentinel NO_VALUE (the shareIn case), reset clears the replay buffer. When it is a real value (the stateIn case), reset re-emits initialValue so the state flow returns to its starting state.

The SharingStarted contract

SharingStarted is a functional interface with a single method:

public fun interface SharingStarted {
    public fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand>
}

It receives the live subscription count of the shared relay and returns a flow of commands. There are three commands defined in the SharingCommand enum:

  • START: begin collecting the upstream.
  • STOP: cancel the upstream collection, but keep what is already in the replay cache.
  • STOP_AND_RESET_REPLAY_CACHE: cancel collection and reset the cached state (clear the buffer for shareIn, restore initialValue for stateIn).

The two built in strategies Eagerly and Lazily are implemented as trivial command flows:

private class StartedEagerly : SharingStarted {
    override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> =
        flowOf(SharingCommand.START)
}

private class StartedLazily : SharingStarted {
    override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> = unsafeFlow {
        var started = false
        subscriptionCount.collect { count ->
            if (count > 0 && !started) {
                started = true
                emit(SharingCommand.START)
            }
        }
    }
}

Eagerly emits a single START and never anything else. Lazily waits for the first subscriber and then emits a single START. Neither one ever emits a STOP, which is why both keep the upstream alive for the rest of the scope's life.

This interview continues for subscribers

Subscribe to Dove Letter for full access to exclusive interviews about Android and Kotlin development.

Become a Sponsor