Memory Leak Prevention: stateIn vs collectLatest
Category: Best Practices Area: KMP Presenters, StateFlow, Coroutines Importance: π΄ CRITICAL - Prevents production memory leaks Discovered: Week 18 (November 2025)
Overview
When creating KMP Presenters that expose StateFlows from repository data, ALWAYS use stateIn() instead of manual collectLatest collectors. Manual collectors create memory leaks because they are not automatically cancelled when the Presenter is cleared.
Critical Rule: Use stateIn(scope, SharingStarted.Eagerly, initialValue) for exposing repository Flows as StateFlows.
The Problem: Memory Leak Pattern
β WRONG: Manual collectLatest (Memory Leak)
class EquipmentPresenter(
private val repository: EquipmentRepository,
private val coroutineScope: CoroutineScope
) {
private val _items = MutableStateFlow<List<Equipment>>(emptyList())
val items: StateFlow<List<Equipment>> = _items.asStateFlow()
init {
loadItems()
}
private fun loadItems() {
coroutineScope.launch {
repository.getAll().collectLatest { items ->
_items.value = items
}
}
}
fun onCleared() {
// β Problem: collectLatest collector NOT cancelled here
// The collector continues running even after Presenter is cleared
// This causes a memory leak!
}
}Why This Leaks:
collectLateststarts a coroutine collector- Collector runs indefinitely (or until Flow completes)
- When
onCleared()is called, the collector is NOT automatically cancelled - Presenter is cleared but collector keeps running
- Presenter remains in memory (garbage collector cannot free it)
- Result: Memory leak
Impact:
- Presenter cannot be garbage collected
- ViewModel holding Presenter cannot be garbage collected
- Repository references remain active
- Memory usage grows with each Presenter instance created
Discovery Story
This pattern was discovered in Week 18 during ViewModel β Presenter migrations:
Timeline:
- Week 17: 6 Presenters created with manual
collectLatestpattern - Week 18: 3 more Presenters created with same pattern
- Week 18: Copilot flagged memory leak during Agent 3 validation
- Week 18: Agent 2 fixed all 9 Presenters immediately
- Week 19: New Presenters used correct
stateInpattern from start
Result: 9 memory leaks prevented before production deployment
The Solution: stateIn Pattern
β CORRECT: stateIn (No Memory Leak)
class EquipmentPresenter(
private val repository: EquipmentRepository,
private val coroutineScope: CoroutineScope
) {
val items: StateFlow<List<Equipment>> = repository.getAll()
.stateIn(
scope = coroutineScope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
fun onCleared() {
// β
Automatic cleanup: stateIn collector cancelled when coroutineScope is cancelled
// No manual cleanup needed!
}
}Why This Works:
stateIncreates a StateFlow that automatically manages the collector- Collector is tied to the provided
coroutineScope - When
coroutineScopeis cancelled,stateIncollector is automatically cancelled - Presenter can be garbage collected normally
- Result: No memory leak
Benefits:
- Automatic lifecycle management
- Cleaner code (no manual collector logic)
- No manual cleanup required
- KMP-compatible (works on Android, iOS, etc.)
Pattern Comparison
Manual collectLatest vs stateIn
| Aspect | Manual collectLatest | stateIn |
|---|---|---|
| Code complexity | Higher (init, loadX(), manual state) | Lower (single expression) |
| Memory safety | β Leaks if not cancelled | β Automatic cleanup |
| Lifecycle management | β Manual cancellation required | β Tied to coroutineScope |
| Initialization | β Requires init/loadX() call | β Automatic (SharingStarted) |
| State exposure | Requires MutableStateFlow + asStateFlow() | Direct StateFlow |
| Lines of code | ~10 lines | ~4 lines |
| Recommended | β NO | β YES |
Code Reduction Example
Before (Manual collectLatest): 10 lines
private val _items = MutableStateFlow<List<Equipment>>(emptyList())
val items: StateFlow<List<Equipment>> = _items.asStateFlow()
init {
loadItems()
}
private fun loadItems() {
coroutineScope.launch {
repository.getAll().collectLatest { items ->
_items.value = items
}
}
}After (stateIn): 4 lines
val items = repository.getAll()
.stateIn(
scope = coroutineScope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)Result: 60% code reduction + memory safety
stateIn Parameters Explained
scope: CoroutineScope
Purpose: The coroutine scope that owns the StateFlow collector
Lifecycle:
- When scope is cancelled,
stateIncollector is automatically cancelled - Use the Presenterβs
coroutineScopeparameter
Example:
class EquipmentPresenter(
private val repository: EquipmentRepository,
private val coroutineScope: CoroutineScope // β Pass this to stateIn
) {
val items = repository.getAll()
.stateIn(coroutineScope, ..., ...) // β Use coroutineScope here
}ViewModel Integration:
class EquipmentViewModel(
private val presenter: EquipmentPresenter
) : ViewModel() {
// Pass viewModelScope to Presenter
init {
// Presenter created with viewModelScope
}
override fun onCleared() {
super.onCleared()
// viewModelScope is cancelled automatically by Android ViewModel
// This cancels the stateIn collector in Presenter
}
}started: SharingStarted
Purpose: Determines when the StateFlow collector starts and stops
Options:
-
SharingStarted.Eagerly (RECOMMENDED for Presenters)
- Starts collector immediately
- Collector runs until scope is cancelled
- Use when: Data should load immediately on Presenter creation
-
SharingStarted.Lazily
- Starts collector on first subscriber
- Collector runs until scope is cancelled
- Use when: Data should load only when observed
-
SharingStarted.WhileSubscribed()
- Starts when first subscriber appears
- Stops when last subscriber disappears (with optional timeout)
- Use when: Want to stop collecting when no subscribers (rare for Presenters)
Recommendation: Use SharingStarted.Eagerly for Presenters
- Presenters typically load data immediately
- Simplifies lifecycle (no delayed start logic)
- Consistent pattern across all Presenters
Example:
val items = repository.getAll()
.stateIn(
scope = coroutineScope,
started = SharingStarted.Eagerly, // β Start immediately
initialValue = emptyList()
)initialValue: T
Purpose: Initial value emitted before first repository value arrives
Use Cases:
- Empty list:
initialValue = emptyList() - Null:
initialValue = null - Loading state:
initialValue = LoadingState.Loading - Default value:
initialValue = DefaultConfig
Example:
// List of items (empty initially)
val items = repository.getAll()
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
// Nullable single item (null initially)
val selectedItem = repository.getById(id)
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
// Loading state (loading initially)
val loadingState = repository.getStatus()
.stateIn(coroutineScope, SharingStarted.Eagerly, LoadingState.Loading)Real-World Examples
Example 1: Simple List (Equipment)
class EquipmentPresenter(
private val repository: EquipmentRepository,
private val coroutineScope: CoroutineScope
) {
// β
Expose all equipment items
val items = repository.getAll()
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
// Methods that modify data
fun deleteItem(id: Long) = coroutineScope.launch {
repository.delete(id)
}
fun insertItem(item: Equipment) = coroutineScope.launch {
repository.insert(item)
}
}Example 2: Dual-Entity Management (Sight + SightMark)
class SightPresenter(
private val repository: SightRepository,
private val coroutineScope: CoroutineScope
) {
// β
Multiple StateFlows (all using stateIn)
val sights = repository.getAllSights()
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
val sightMarks = repository.getAllSightMarks()
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
val selectedSight = repository.getSelectedSight()
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val measurementSystem = repository.getMeasurementSystem()
.stateIn(coroutineScope, SharingStarted.Eagerly, MeasurementSystem.METRIC)
}Example 3: Complex State (Multiple Flows)
class RoundScoringPresenter(
private val roundRepo: RoundRepository,
private val scoreRepo: ScoreRepository,
private val coroutineScope: CoroutineScope
) {
// β
All Flows use stateIn
val currentRound = roundRepo.getCurrentRound()
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val scores = scoreRepo.getScoresForRound()
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
val statistics = scoreRepo.getStatistics()
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
val isLoading = combine(
currentRound,
scores,
statistics
) { round, scores, stats ->
round == null && scores.isEmpty() && stats == null
}.stateIn(coroutineScope, SharingStarted.Eagerly, true)
}Testing Presenters with stateIn
Unit Test Pattern
class EquipmentPresenterTest {
private lateinit var mockRepository: EquipmentRepository
private lateinit var testScope: TestCoroutineScope
private lateinit var presenter: EquipmentPresenter
@Before
fun setup() {
mockRepository = mockk()
testScope = TestCoroutineScope()
// Mock repository returns Flow
every { mockRepository.getAll() } returns flowOf(
listOf(equipment1, equipment2)
)
// Create presenter with test scope
presenter = EquipmentPresenter(mockRepository, testScope)
}
@Test
fun `items emits repository data`() = testScope.runBlockingTest {
// Advance coroutines (stateIn collector starts)
advanceUntilIdle()
// Verify StateFlow has expected value
assertEquals(listOf(equipment1, equipment2), presenter.items.value)
}
@After
fun tearDown() {
// Cancel test scope (cleans up stateIn collector)
testScope.cleanupTestCoroutines()
}
}Key Points:
- Use
TestCoroutineScopefor testing - Call
advanceUntilIdle()to process stateIn initialization - Verify
StateFlow.valuedirectly - Call
cleanupTestCoroutines()in tearDown
Migration Guide: collectLatest β stateIn
If you have existing Presenters using the wrong pattern, migrate them:
Step 1: Identify Manual Collectors
Look for this pattern:
private val _items = MutableStateFlow<T>(initialValue)
val items: StateFlow<T> = _items.asStateFlow()
private fun loadItems() {
scope.launch {
repository.getX().collectLatest { data ->
_items.value = data
}
}
}Step 2: Replace with stateIn
val items = repository.getX()
.stateIn(scope, SharingStarted.Eagerly, initialValue)Step 3: Remove Manual Load Methods
Delete:
init { loadItems() }blocksloadItems()methods_itemsprivate MutableStateFlow.asStateFlow()calls
Step 4: Update Tests
Update tests to use advanceUntilIdle() if using TestCoroutineScope:
@Test
fun `test items`() = testScope.runBlockingTest {
advanceUntilIdle() // β Add this to process stateIn initialization
assertEquals(expected, presenter.items.value)
}Example Migration
Before:
class EquipmentPresenter(
private val repository: EquipmentRepository,
private val coroutineScope: CoroutineScope
) {
private val _items = MutableStateFlow<List<Equipment>>(emptyList())
val items: StateFlow<List<Equipment>> = _items.asStateFlow()
init {
loadItems()
}
private fun loadItems() {
coroutineScope.launch {
repository.getAll().collectLatest { items ->
_items.value = items
}
}
}
}After:
class EquipmentPresenter(
private val repository: EquipmentRepository,
private val coroutineScope: CoroutineScope
) {
val items = repository.getAll()
.stateIn(coroutineScope, SharingStarted.Eagerly, emptyList())
}Lines of code: 16 β 5 (69% reduction)
Enforcement
Code Review Checklist
When reviewing Presenter code:
- β
All repository Flows use
stateIn - β No manual
collectLatestcollectors - β No
MutableStateFlowwith manual updates from repository Flows - β No
loadX()methods that callcollectLatest - β
coroutineScopepassed tostateIn - β
SharingStarted.Eagerlyused - β
Appropriate
initialValueprovided
Automated Detection
Consider adding a lint rule or static analysis check:
// Detectable pattern (REJECT in code review):
repository.getX().collectLatest { _stateFlow.value = it }
// Required pattern (APPROVE in code review):
repository.getX().stateIn(scope, SharingStarted.Eagerly, initialValue)Performance Considerations
Memory Impact
Manual collectLatest (Memory Leak):
- Memory usage grows linearly with Presenter instances
- Example: 100 screens visited = 100 leaked Presenters in memory
- Impact: App slowdown, potential OOM crashes
stateIn (No Leak):
- Memory usage stays constant (only active Presenters in memory)
- Garbage collector can free cleared Presenters
- Impact: No performance degradation over time
CPU Impact
Both patterns have similar CPU usage:
collectLatestandstateInboth collect from repository FlowstateInhas negligible overhead for automatic cancellation- Performance difference is not measurable in practice
Recommendation: Always use stateIn (memory safety > negligible CPU difference)
History
Week 17 (November 2025):
- 6 Presenters created with manual
collectLatestpattern - No memory leak detected initially
Week 18 (November 2025):
- 3 more Presenters created with same pattern
- Copilot flagged memory leak during Agent 3 validation
- Agent 2 fixed all 9 Presenters (Week 17 + Week 18) immediately
- Pattern documented in CLAUDE.md
Week 19 (November 2025):
- PlungerPresenter created with CORRECT
stateInpattern from start - Evidence: Pattern learning successful (Agent 2 internalized fix)
Result: 9 memory leaks prevented, pattern established for all future work
Related Documentation
- Weeks 17-19 Overview
- Agent 2 Week 17-19 Summary
- Agent 3 Week 17-19 Summary
- How to Add a New ViewModel
Tags
best-practice memory-leak statein collectlatest kmp presenter-pattern coroutines stateflow critical
Summary
Rule: Always use stateIn(scope, SharingStarted.Eagerly, initialValue) for exposing repository Flows in Presenters.
Why: Prevents memory leaks, cleaner code, automatic lifecycle management.
When: Every time you create a Presenter that exposes repository data as StateFlow.
Enforcement: Code review checklist, pattern documented in CLAUDE.md, validated by Agent 3.