Setelah ViewModel muncul, muncul pertanyaan: bagaimana cara mengumpan state UI-nya ke Compose? Sebelum Compose, jawaban standar adalah LiveData — observable yang sadar lifecycle. Sejak Kotlin Coroutines matang, StateFlow menjadi pilihan yang lebih cocok karena merupakan bagian dari Coroutines dan tidak bergantung pada Android framework. Bab ini membandingkan kedua opsi, menunjukkan pola single source of truth, dan menjelaskan collectAsState() di Compose.

StateFlow vs LiveData #

LiveData adalah observable yang memegang nilai terakhir dan otomatis menghormati lifecycle — observer hanya menerima update saat LifecycleOwner setidaknya STARTED. StateFlow juga memegang nilai terakhir, namun tidak otomatis menghormati lifecycle. Pendengar StateFlow perlu membatalkan collection secara eksplisit (di Compose, collectAsState() menangani ini otomatis).

AspekLiveDataStateFlow
Sadar lifecycleOtomatisManual / via collectAsState()
Bekerja dengan coroutinesPerlu adapter liveData { }Native, built-in
Initial value wajibTidakYa
Conflate (gabung emisi cepat)TidakYa
Stable across process deathTidak otomatisTidak otomatis (butuh SavedStateHandle)

Untuk Compose, StateFlow lebih idiomatis karena seluruh pipeline tetap berada di Coroutines ecosystem.

Single Source of Truth #

Pola single source of truth (SSOT) berarti hanya ada satu tempat yang memegang state UI — biasanya StateFlow<UiState> di ViewModel. UI hanya membaca state tersebut; tidak pernah menyimpan state-nya sendiri yang relevan lintas render. Event dari UI (klik tombol, input) diteruskan ke ViewModel lewat method, lalu ViewModel memperbarui state.

kotlin Pola SSOT di ViewModel
data class TugasUiState(val items: List<String>, val isLoading: Boolean, val error: String?)

class TugasViewModel(private val repo: TugasRepository) : ViewModel() {
    private val _state = MutableStateFlow(TugasUiState(items = emptyList(), isLoading = true, error = null))
    val state: StateFlow<TugasUiState> = _state.asStateFlow()

    fun tambahTugas(nama: String) {
        if (nama.isBlank()) {
            _state.update { it.copy(error = "Nama tidak boleh kosong") }
            return
        }
        viewModelScope.launch {
            repo.tambah(nama)
            _state.update { it.copy(items = repo.muat(), error = null) }
        }
    }
}

Pola ini membuat setiap perubahan state traceable: dari method ViewModel → repository → update StateFlow → recompose UI. Bila ada bug, kita tahu persis di titik mana state berubah.

collectAsState() di Compose #

Compose menyediakan extension collectAsState() yang mengkonversi StateFlow menjadi State<T> Compose. Setiap kali StateFlow mengeluarkan nilai baru, composable yang membaca State<T> akan dijadwalkan untuk recomposition.

kotlin collectAsState di Compose
@Composable
fun TugasScreen(vm: TugasViewModel = viewModel()) {
    val state by vm.state.collectAsState()
    Column {
        if (state.isLoading) CircularProgressIndicator()
        state.error?.let { Text(it, color = Color.Red) }
        LazyColumn { items(state.items) { Text(it) } }
    }
}

collectAsState() juga menerima parameter initialValue untuk memberi nilai awal sebelum flow mengeluarkan nilai pertama (terutama berguna untuk SharedFlow yang tidak punya initial value).

Simulasi StateFlow sebagai SSOT #

Cuplikan berikut memodelkan BuahViewModel dengan StateFlow sebagai SSOT, serta ComposeCollector yang mensimulasikan collectAsState():

kotlin
// stateflow_untuk_ui.kt
// Membandingkan StateFlow vs LiveData dan menunjukkan single source of truth
// di ViewModel + collectAsState di sisi UI (disimulasikan).

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

data class UiState<T>(val data: T, val isLoading: Boolean, val error: String?)

class Repository {
    private val data = mutableListOf("Apel", "Jeruk", "Pisang")

    fun fetch(): List<String> = data.toList()
    fun add(item: String) { data.add(item) }
}

// StateFlow sebagai single source of truth di ViewModel
class BuahViewModel(private val repo: Repository) {
    private val _state: MutableStateFlow<UiState<List<String>>> =
        MutableStateFlow(UiState(data = emptyList<String>(), isLoading = true, error = null))
    val state: StateFlow<UiState<List<String>>> = _state.asStateFlow()

    fun load() {
        _state.value = UiState(data = repo.fetch(), isLoading = false, error = null)
    }

    fun add(nama: String) {
        if (nama.isBlank()) {
            _state.value = _state.value.copy(error = "Nama tidak boleh kosong")
            return
        }
        repo.add(nama)
        _state.value = UiState(data = repo.fetch(), isLoading = false, error = null)
    }
}

// Simulasi collectAsState di Compose — terakhir nilai flow di-collect
class ComposeCollector<T>(initial: T, private val flow: StateFlow<T>) {
    private var current: T = initial

    fun collect(): T {
        current = flow.value
        println("  [Compose] render state = $current")
        return current
    }
}

fun main() {
    val repo = Repository()
    val vm = BuahViewModel(repo)
    val ui = ComposeCollector(UiState(emptyList<String>(), true, null), vm.state)

    println("=== Render awal ===")
    ui.collect()

    println("\n=== Setelah load() ===")
    vm.load()
    ui.collect()

    println("\n=== Tambah item valid ===")
    vm.add("Mangga")
    ui.collect()

    println("\n=== Tambah item invalid ===")
    vm.add("")
    ui.collect()

    println("\n=== Tambah item valid lagi ===")
    vm.add("Anggur")
    ui.collect()
}

Output yang Dihasilkan:

text
=== Render awal ===
  [Compose] render state = UiState(data=[], isLoading=true, error=null)

=== Setelah load() ===
  [Compose] render state = UiState(data=[Apel, Jeruk, Pisang], isLoading=false, error=null)

=== Tambah item valid ===
  [Compose] render state = UiState(data=[Apel, Jeruk, Pisang, Mangga], isLoading=false, error=null)

=== Tambah item invalid ===
  [Compose] render state = UiState(data=[Apel, Jeruk, Pisang, Mangga], isLoading=false, error=Nama tidak boleh kosong)

=== Tambah item valid lagi ===
  [Compose] render state = UiState(data=[Apel, Jeruk, Pisang, Mangga, Anggur], isLoading=false, error=null)

Perhatikan bahwa setiap kali add(nama) dipanggil, UI yang membaca state akan langsung menerima nilai baru — termasuk pesan error bila input kosong. Inilah inti SSOT: UI selalu konsisten dengan state di ViewModel.

Referensi Lanjutan #

Selanjutnya #

State sudah terstruktur. Sekarang kita perlu cara menjalankan operasi asinkron (network, database) dengan benar di Android. Chapter Coroutines di Android akan membahas viewModelScope, lifecycleScope, dan pengaturan dispatcher.