DEV Community

myougaTheAxo
myougaTheAxo

Posted on

StateFlow + Jetpack Compose: The Pattern AI Always Gets Right

I've been using AI code generation tools to build Android apps for months now. And there's one pattern that the AI generates every single time — almost religiously — and it's the pattern that works best in production.

StateFlow + Jetpack Compose.

If you've been confused about when to use StateFlow, how it connects to Compose, and why you should care about WhileSubscribed(5000), this is the article that explains it.


The Challenge: Keeping UI in Sync With Data

Imagine you're building a note-taking app.

You have a database of notes. When you update a note, the UI should reflect that change immediately. When you delete a note, it disappears from the list. When you navigate away and come back, the data is still there.

Here's the problem: how do you wire the database to the UI without:

  • Memory leaks (listeners that never get unregistered)
  • Stale data (UI showing old data after a refresh)
  • Lifecycle issues (collecting data while the screen is backgrounded)
  • Boilerplate (writing callback chains everywhere)

That's what StateFlow solves.


StateFlow: A State Holder That Emits Updates to Compose

StateFlow<T> is a coroutine-friendly state holder that emits a new value every time its state changes. When Compose observes a StateFlow, it automatically recomposes whenever the value changes.

Here's the mental model:

Database (Room)
  ↓ emits Flow<List<Note>>
Repository
  ↓ converts to StateFlow
ViewModel
  ↓ collected in Compose with lifecycle awareness
Screen (Composable)
  ↓ recomposes when state changes
Enter fullscreen mode Exit fullscreen mode

Each layer has one job. The database doesn't know about the UI. The ViewModel doesn't know about Compose. And Compose doesn't know about the database. Changes flow downward, automatically.


The Pattern AI Generates Every Time

I've generated 8 complete Android apps with AI. Every single one used this exact pattern. Here's why that matters — it means this is the idiomatic way to do it.

Step 1: DAO Returns Flow>

Your data access object queries the database and returns a Flow. A Flow emits a new list every time the data changes:

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY createdAt DESC")
    fun getAllNotes(): Flow<List<Note>>

    @Insert
    suspend fun insert(note: Note)

    @Update
    suspend fun update(note: Note)

    @Delete
    suspend fun delete(note: Note)
}
Enter fullscreen mode Exit fullscreen mode

The magic word is Flow. Not List. Not suspend fun that returns a one-time value. A Flow that emits whenever the data changes.

Room handles this automatically — when any INSERT, UPDATE, or DELETE happens to the notes table, the Flow emits the updated list. You don't write that logic yourself.

Step 2: Repository Exposes the Flow

The Repository is the single source of truth for data. It sits between the DAO and the ViewModel. If you ever add a second data source (like a remote API), you change the Repository, not every file that uses the data.

class NoteRepository(private val noteDao: NoteDao) {
    val notes: Flow<List<Note>> = noteDao.getAllNotes()

    suspend fun insert(note: Note) = noteDao.insert(note)
    suspend fun update(note: Note) = noteDao.update(note)
    suspend fun delete(note: Note) = noteDao.delete(note)
}
Enter fullscreen mode Exit fullscreen mode

Simple. The Repository doesn't transform the Flow — it just exposes it.

Step 3: ViewModel Converts to StateFlow Using stateIn()

Here's where lifecycle awareness enters. The ViewModel converts the Flow from the Repository into a StateFlow using stateIn():

class NoteViewModel(private val repository: NoteRepository) : ViewModel() {
    val notes: StateFlow<List<Note>> = repository.notes
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun insertNote(note: Note) {
        viewModelScope.launch {
            repository.insert(note)
        }
    }

    fun updateNote(note: Note) {
        viewModelScope.launch {
            repository.update(note)
        }
    }

    fun deleteNote(note: Note) {
        viewModelScope.launch {
            repository.delete(note)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Three parameters to stateIn():

  1. scopeviewModelScope. This means the collection stops when the ViewModel is destroyed.
  2. startedSharingStarted.WhileSubscribed(5000). This is the key one.
  3. initialValueemptyList(). What to show while the data is loading.

Step 4: Compose Collects With collectAsStateWithLifecycle()

Now in the Composable, you collect the StateFlow with lifecycle awareness:

@Composable
fun NoteListScreen(viewModel: NoteViewModel = viewModel()) {
    val notes by viewModel.notes.collectAsStateWithLifecycle()

    LazyColumn {
        items(notes, key = { it.id }) { note ->
            NoteItem(note)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

collectAsStateWithLifecycle() is a Compose function that converts a StateFlow to a State. The word "Lifecycle" is important — it stops collecting when the screen leaves the foreground.


Why WhileSubscribed(5000) Is the Right Choice

The default SharingStarted option is Eagerly, which means the StateFlow collects from the Flow immediately and keeps collecting forever.

WhileSubscribed(5000) is different:

  • While there is at least one collector (like your Composable screen)
  • Subscribed means actively listening
  • 5000 milliseconds (5 seconds) of grace period

Here's what that means in practice:

  1. User is reading the note list. Compose is collecting. Database changes stream to the UI.
  2. User navigates away. Compose stops collecting.
  3. The StateFlow waits 5 seconds to see if Compose comes back.
  4. If it doesn't, the StateFlow stops collecting from the repository, which stops collecting from the database.
  5. Result: zero wasted database queries while the screen is backgrounded.

If the user navigates back, Compose starts collecting again, and the StateFlow reconnects to the repository.

Why 5 seconds? It's a balance:

  • Short enough that you're not wasting database queries
  • Long enough that brief navigation (like showing a dialog) doesn't restart everything

You can tune this number based on your app, but 5000 milliseconds is the convention.


Why This Pattern Works

Automatic UI Refresh

When the database changes, the DAO emits a new list automatically. That flows to the Repository, to the StateFlow, to Compose, and the screen recomposes. No manual refresh buttons. No polling timers. No cache invalidation.

Lifecycle-Aware

The collection stops when you navigate away. The ViewModel survives configuration changes (like rotating your phone), but the collection automatically pauses and resumes with the screen lifecycle. You can't leak listeners because the framework handles it.

No Memory Leaks

Because the StateFlow is attached to viewModelScope, when the ViewModel is destroyed, the scope is cancelled, and the collection stops. No dangling references. No WeakReferences. No manual unsubscribe logic.

Testable

In a unit test, you can collect the StateFlow directly:

@Test
fun testNotesEmitted() = runTest {
    val viewModel = NoteViewModel(repository)

    val notes = viewModel.notes.take(1).toList()

    assert(notes[0] == expectedNotes)
}
Enter fullscreen mode Exit fullscreen mode

No UI layer needed. You can test the ViewModel in isolation.


The Cost of Getting This Right

Every time I've generated an app with AI, the resulting code uses this pattern. But here's the thing — most Android developers get this wrong.

I've reviewed code where:

  • The DAO returns List<T> instead of Flow<List<T>> (no reactive updates)
  • The ViewModel collects directly from the repository in init {} and caches the value (lifecycle-unaware)
  • The Composable observes mutable state instead of a StateFlow (race conditions)
  • The coroutine scope is unmanaged (potential memory leaks)

This pattern requires understanding Kotlin coroutines, Flow, StateFlow, Compose state, and lifecycle. It's not trivial. But once you understand it, it becomes the default way you write Android code.


All My Templates Use This Exact Pattern

I've built 8 AI-generated Android app templates — Habit Tracker, Budget Manager, Task Manager, Expense Memo, Meeting Timer, Unit Converter, Countdown Timer, and Habit Tracker.

Every single one uses:

  • Room database with DAO returning Flow<T>
  • Repository for abstraction
  • ViewModel with stateIn(viewModelScope, WhileSubscribed(5000), initialValue)
  • Jetpack Compose collecting with collectAsStateWithLifecycle()

If you want to see complete, working code that uses this pattern, all 8 templates are on Gumroad. You get the full Kotlin source, Material3 design, and a working foundation you can build on.

No boilerplate that you'll replace. No patterns that are outdated. Just the pattern that works.


What's your biggest confusion about StateFlow and Compose? Drop it in the comments — I'm reading every one.

Top comments (0)