mavericks-mvi-en

Use when creating or modifying Mavericks 3.x ViewModel / State / Hilt registration / Compose or Fragment subscriptions / Async requests / DeliveryMode one-shot events in the project

Mavericks MVI Guide

A screen is a function of state. State is immutable, thread-safe, and its rendering result is independent of event ordering. Three core classes: MavericksState, MavericksViewModel, MavericksView.

Quick Reference

I want to...CodeSection
Compose subscribe to the whole statevm.collectAsStateWithLifecycle()§6
Compose subscribe to a single propertyvm.collectAsStateWithLifecycle(State::prop)§6
Fragment subscribe to statevm.onEach(State::prop) { } (inside onViewCreated)§6
Emit a one-shot eventFragment: onEach(State::evt, uniqueOnly()) { }; Compose: LaunchedEffect(effect)§7
Issue a network / DB requestsuspend { api() }.execute { copy(x = it) }§2
Subscribe to a long-lived Flowflow.setOnEach { copy(x = it) } (inside init)§2
Keep the old data while refreshing.execute(retainValue = State::x) { }§2
Switch to Default / IO dispatcher.execute(Dispatchers.Default) { }§2
Read current state once (inside VM)withState { state -> }§3
Await the latest state (suspend)awaitState()§3
Lifecycle-gated coroutinelifecycleScope.launch { repeatOnLifecycle(STARTED) { } }§6

1. State Design

Mandatory rules enforced by reflection checks in debug mode:

  • MUST be a data class, all constructor parameters val, every field must have a default value, visibility public
  • The declared field type MUST NEVER be one of the following containers: ArrayList / HashMap / android.util.SparseArray / androidx.collection.LongSparseArray / SparseArrayCompat / androidx.collection.ArrayMap / android.util.ArrayMap (both packages are banned)
  • A field's type MUST NEVER be a subtype of Function / KCallable (lambdas, () -> Unit, KFunction — all rejected)
  • Fields MUST NEVER hold Context / View
  • Once a State instance has been accepted by the state store it MUST NOT be mutated (MutableStateChecker compares hashCode before/after)
  • Reducers MUST be pure functions; in debug mode they are executed twice and the results are compared

The check only inspects the declared field type. Reflection only sees the declared type (Map<K, V>), not the concrete runtime class (HashMap). So declaring val items: Map<K, V> will pass even when the runtime instance is a HashMap. Appendix A Option B relies on this.

data class XxxState(
    val items: Async<List<Item>> = Uninitialized,
    val firstName: String = "",
    val email: String = "",
    val effect: Effect? = null,                 // see §7
) : MavericksState {
    // Derived properties — use get() for clear semantics and no backing field
    val isEmpty: Boolean get() = items is Success && items()?.isEmpty() == true
    val hasValidEmail: Boolean get() = EMAIL_PATTERN.matches(email)
}

Key properties of derived properties: no backing field, and they do NOT participate in equals / hashCode / copy / componentN. Therefore data class equality is fully determined by constructor parameters. They can be subscribed to via onEach(State::derived).

Principle: Any value that can be computed via a derived property MUST NEVER be stored as an independent field. Updating Map / Set fields: see Appendix A.

Process-Death Persistence

Mavericks automatically keeps VM instances alive across configuration changes and back-stack scenarios; @PersistState is only needed when the process has been killed by the system and is being recreated:

data class FormState(
    @PersistState val firstName: String = "",
) : MavericksState

The field MUST be Parcelable / Serializable / primitive; it MUST NOT be an Async (restoring as Loading would leave the UI stuck in a loading state forever).


2. ViewModel

class XxxViewModel @AssistedInject constructor(
    @Assisted initialState: XxxState,
    private val repository: XxxRepository,
) : MavericksViewModel<XxxState>(initialState) {

    init {
        loadData()
        // Subscribe to state changes (for derived side effects; onEach supports up to 7-property overloads)
        onEach(XxxState::email) { validateEmail(it) }

        // Long-lived Flow subscription: establish once inside init, do NOT wrap it in a public method
        repository.dataFlow.execute { copy(items = it) }
    }

    fun loadData() = suspend { repository.fetchList() }.execute { copy(items = it) }

    // Refresh while retaining the old data: Loading(oldList) → Success(newList) or Fail(e, oldList)
    fun refresh() = suspend { repository.fetchList() }
        .execute(retainValue = XxxState::items) { copy(items = it) }

    fun heavyWork() = suspend { repository.compute() }
        .execute(Dispatchers.Default) { copy(result = it) }

    // setState is enqueued asynchronously; wrap with withState to read the current value
    fun increment() = withState { s -> setState { copy(count = s.count + 1) } }

    @AssistedFactory
    interface Factory : AssistedViewModelFactory<XxxViewModel, XxxState> {
        override fun create(state: XxxState): XxxViewModel
    }

    companion object :
        MavericksViewModelFactory<XxxViewModel, XxxState> by hiltMavericksViewModelFactory()
}

⚠️ onEach Uses collectLatest Semantics

All _internal* subscription functions in the source code are built on collectLatestif the previous action has not finished when a new value arrives, it will be cancelled:

onEach(State::items) {
    delay(5000)            // When new items arrive, this delay is cancelled
    logAnalytics(it)       // The previous log will never be emitted
}

Implication: the onEach action should be short and fast (use it only for derived side effects); long-running work should go through execute; when you truly need "run the full action for every value," use flow.collect { } instead.

Async<T>

Uninitialized              : Async<Nothing>, Incomplete   // shouldLoad = true
Loading(value = oldValue?) : Async<T>,       Incomplete
Success(value)             : Async<T>                     // complete = true
Fail(error, value?)        : Async<T>                     // complete = true, shouldLoad = true

Getting the value: operator fun invoke(): T?state.items() returns the Success value or null.

Use Incomplete in when to match both not-yet-done states at once:

when (val a = state.items) {
    is Incomplete -> showSpinner()
    is Success    -> render(a())
    is Fail       -> showError(a.error)
}

Metadata fields: complete / shouldLoad are useful for pagination and retry decisions.

Types Supported by execute

suspend () -> T / Flow<T> / Deferred<T> / (mvrx-rxjava2) Observable / Single / Completable.

🚨 execute does NOT switch threads for you. The default dispatcher = Dispatchers.Main.immediate (the default of viewModelScope, per the KDoc) — suspend { heavyWork() }.execute { ... } still runs on the main thread.

  • CPU-bound work (JSON parsing, image processing, heavy computation) → you MUST pass Dispatchers.Default
  • Blocking IO (files, raw DB APIs, synchronous networking) → you MUST pass Dispatchers.IO
  • Retrofit suspend APIs / Room suspend DAOs → they already switch threads internally; the default is fine
suspend { api.fetchList() }.execute { copy(items = it) }                       // OK: Retrofit switches to IO internally
suspend { json.parseLargeBlob() }.execute(Dispatchers.Default) { copy(...) }   // Must be explicit
suspend { File(path).readText() }.execute(Dispatchers.IO) { copy(...) }        // Must be explicit

The global default can be customised via Mavericks.initialize(). When the VM is destroyed (onCleared callback) all subscriptions are cancelled automatically.

setOnEach — Map a Flow Directly to State (No Async Wrapping)

Use this when the Flow represents a long-lived data source (DB changes, live pushes) rather than a one-shot request:

init {
    repository.unreadCountFlow.setOnEach { count -> copy(unreadCount = count) }
}

It is syntactic sugar equivalent to flow.onEach { setState { reducer(it) } }.


3. Threading Model

fun demo() {
    println("A"); setState { println("B"); copy(count = 1) }
    println("C"); withState { println("D") }; println("E")
}
// Output: A, C, E, B, D
  • setState is enqueued asynchronously; within the same method you cannot expect the new value to be visible immediately after calling setState
  • withState internally is stateStore.get(action); the callback is also enqueued, and runs after all pending setState calls have been drained
  • To obtain the latest state in a suspend context, use awaitState() (internally withState + CompletableDeferred)
  • You MUST NEVER perform blocking IO or heavy computation inside a setState / withState block
  • The setState queue and the execute dispatcher are two independent things: execute runs on Main.immediate by default, but the results it produces still go through the state store's own serial queue when written via the reducer, so the two do not interfere with each other

4. Hilt DI Registration

Forgetting to register causes a runtime crash. MavericksViewModelComponent is a child of SingletonComponent (not the standard ViewModelComponent):

@Module
@InstallIn(MavericksViewModelComponent::class)
interface XxxViewModelModule {
    @Binds @IntoMap @ViewModelKey(XxxViewModel::class)
    fun bind(factory: XxxViewModel.Factory): AssistedViewModelFactory<*, *>
}

For a new feature, just add one line to the existing ViewModelModule of the owning business module.

Customising initialState (Compatible with Hilt)

MavericksViewModelFactory exposes two override points: create() and initialState(). hiltMavericksViewModelFactory() only takes over create; initialState can still be overridden independently:

companion object : MavericksViewModelFactory<MyViewModel, MyState>
    by hiltMavericksViewModelFactory<MyViewModel, MyState>() {

    override fun initialState(vc: ViewModelContext): MyState {
        val args: MyArgs = vc.args()
        return MyState(itemId = args.itemId)
    }
    // ❌ Do NOT override create() yourself in a Hilt project
}

ViewModelContext exposes activity / fragment / args / owner.


5. Fragment Arguments

fragment.arguments = myArgs.asMavericksArgs()
private val args: MyArgs by args()

data class MyState(
    val itemId: String,
    val item: Async<Item> = Uninitialized,
) : MavericksState {
    constructor(args: MyArgs) : this(args.itemId)   // Called automatically by Mavericks
}

asMavericksArgs() places the arguments under the bundle key Mavericks.KEY_ARG; by args() reads from the same key.


6. Observing UI

Jetpack Compose (mavericks-compose)

You MUST use VM.collectAsStateWithLifecycle (com.airbnb.mvrx.compose.collectAsStateWithLifecycle), which automatically stops collecting when the lifecycle enters STOPPED. Do NOT use vm.collectAsState() — it is the legacy API and is not lifecycle-aware.

import com.airbnb.mvrx.compose.collectAsStateWithLifecycle
import com.airbnb.mvrx.compose.mavericksViewModel

@Composable
fun Screen() {
    val vm: XxxViewModel = mavericksViewModel()
    // Or mavericksActivityViewModel<XxxViewModel>() to bind to the Activity

    val state by vm.collectAsStateWithLifecycle()                     // Whole state
    val count by vm.collectAsStateWithLifecycle(XxxState::count)      // Single property
    val canSubmit by vm.collectAsStateWithLifecycle { it.hasValidEmail && it.firstName.isNotBlank() }

    // Stricter activation threshold: only collect when the lifecycle reaches RESUMED
    val hot by vm.collectAsStateWithLifecycle(
        XxxState::realtime,
        minActiveState = Lifecycle.State.RESUMED,
    )
}

⚠️ Import conflict: com.airbnb.mvrx.compose.collectAsStateWithLifecycle and androidx.lifecycle.compose.collectAsStateWithLifecycle share the same name. When using both, add an alias to one of them: import ... as collectStateFlowWithLifecycle.

stateFlow is Flow<S>, NOT StateFlow<S> (deliberately, to prevent synchronous access), so there is no .value. To read the state once: use withState { } inside the VM, or awaitState() in a suspend context.

Best practice: subscribe to specific properties, and break large composables into smaller ones. Scope: mavericksViewModel() binds to the nearest LocalLifecycleOwner by default (NavBackStackEntry / Fragment / Activity).

XML / Fragment (implementing MavericksView)

class MyFragment : Fragment(R.layout.fragment_my), MavericksView {
    private val vm: MyViewModel by fragmentViewModel()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        vm.onEach(MyState::items) { adapter.submitList(it) }
        vm.onAsync(MyState::submit, deliveryMode = uniqueOnly(),
                   onFail = { toast(it.message ?: "") })
    }

    override fun invalidate() = Unit   // New code uses fine-grained onEach; leave invalidate empty
}

invalidate() vs onEach: the former is the legacy XML API — it is called on every state change and is intended for whole-screen rendering; the latter is a fine-grained subscription with better performance. In new code always use onEach and leave invalidate() empty.

ViewModel scope delegates:

DelegateScope
fragmentViewModel()The current Fragment (child Fragments can access it; parent and sibling Fragments get independent instances)
parentFragmentViewModel()Walks up the parent chain looking for a VM of the same type; creates one on the topmost parent Fragment if none is found
activityViewModel()The current Activity — preferred for sharing across Fragments
existingViewModel()Same as activityViewModel, but requires the VM to already exist; otherwise it throws
navGraphViewModel(id)Scoped to a Navigation graph (requires mvrx-navigation)

keyFactory: use it when the same scope needs multiple VM instances of the same type. Example: each page in a ViewPager2 uses a DetailViewModel but distinguishes them by detailId:

private val vm: DetailViewModel by fragmentViewModel(keyFactory = { args.detailId })

DeliveryMode (only available inside MavericksView)

⚠️ The deliveryMode parameter only exists on the onEach / onAsync member extensions of MavericksView:

  • ❌ Inside a ViewModel's init { onEach(...) } — the ViewModel's onEach signature has no such parameter, passing one will fail to compile
  • ❌ Compose mavericksViewModel() — does not go through MavericksView, so there is no DeliveryMode concept; use minActiveState to control the lifecycle state at which collection is active
  • ✅ Only available when a Fragment / Activity implements MavericksView and calls vm.onEach(...)
ModeBehaviourUse case
RedeliverOnStart (default)Redelivers the latest value on every STOPPED → STARTED transitionRendering UI
UniqueOnly(id)On STOPPED → STARTED, delivers the latest value only if it has changedOne-shot side effects (Toast / navigation / Dialog)

uniqueOnly(customId) helper: returns UniqueOnly("{mvrxViewId}_UniqueOnly_{customId?}"); mvrxViewId is unique across Fragment instances. Call it without arguments in most cases; when you subscribe to the same property multiple times inside the same Fragment, pass distinct customIds to avoid a runtime subscription-id collision.

Do NOT hand-write UniqueOnly("nav") — it will collide across instances.

Keeping Async Callbacks Safe — repeatOnLifecycle

When the callbacks of onEach / onAsync do delays or animations, accessing binding after the UI has been destroyed will crash. In Activities / Fragments, use lifecycleScope.launch directly (no viewLifecycleOwner prefix):

vm.onAsync(MyState::data, onFail = {
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            binding.error.isVisible = true
            delay(4000)
            binding.error.isVisible = false
        }
    }
})

Semantics: runs the block when the lifecycle enters STARTED, cancels the block on STOPPED, and re-runs the block the next time STARTED is reached.

⚠️ Do NOT use whenStarted / whenResumed / whenCreated — these have been deprecated since androidx.lifecycle 2.4+; they only suspend and never cancel, making leaks easy. Always use repeatOnLifecycle.


7. One-Shot Events (the Effect Pattern)

Mavericks has no official notion of an Effect. Project convention: put a nullable field in State to carry one-shot events, and clear it as soon as the UI has consumed it.

⚠️ Limitations and Applicability

Because the val effect: Effect? = null field is fundamentally a single-value slot, three hard constraints follow:

  • Single-value overwrite: when two effects are published in quick succession, the second overwrites the first — if the UI has not yet received the first effect, it is lost permanently.
  • No ordering guarantee: when two effects are published within a very short interval, the intermediate state can be filtered out by distinctUntilChanged, so the consumer only sees the latter.
  • Consumption depends on a live UI: if an effect is published while the UI is in the STOPPED state, and another effect arrives before the UI wakes back up, the old one gets overwritten and the user will never see it.

Applicable scenarios: low-frequency, overwrite-tolerant one-shot side effects — a single Toast, a single navigation, the result of a form submission, opening/closing a dialog. Not applicable:

  • High-frequency event streams (Toast queues, continuous analytics) → use a cumulative list such as val toasts: List<String> = emptyList(), and let the UI clear it in one go after consumption
  • Strict exactly-once semantics (no event may be lost) → no perfect solution exists within the Mavericks model; design the product to avoid that requirement
sealed interface Effect {
    data class ShowToast(val msg: String) : Effect
    data class Navigate(val target: Screen) : Effect
    data object DismissDialog : Effect
}

data class XxxState(val effect: Effect? = null, /* ... */) : MavericksState

// ViewModel
fun submit() = setState { copy(effect = Effect.ShowToast("Submitted")) }
fun consumeEffect() = setState { copy(effect = null) }

Fragment subscription (uniqueOnly is required):

vm.onEach(XxxState::effect, deliveryMode = uniqueOnly()) { effect ->
    when (effect) {
        is Effect.ShowToast  -> toast(effect.msg)
        is Effect.Navigate   -> router.navigate(effect.target)
        Effect.DismissDialog -> dialog?.dismiss()
        null -> return@onEach
    }
    vm.consumeEffect()
}

Without uniqueOnly(): when the subscription is rebuilt after a rotation, the default RedeliverOnStart will redeliver the last effect, and the user will see a duplicated Toast / navigation.

Compose subscription:

val effect by vm.collectAsStateWithLifecycle(XxxState::effect)
LaunchedEffect(effect) {
    when (val e = effect) {
        is Effect.ShowToast -> snackbarHost.showSnackbar(e.msg)
        is Effect.Navigate  -> navController.navigate(e.target.route)
        else -> return@LaunchedEffect
    }
    vm.consumeEffect()
}

LaunchedEffect(effect) keys off the effect value itself — it restarts the block whenever effect changes; after the block finishes, consumeEffect() resets effect to null, and the block fires again only on the next new event. This mechanism naturally avoids duplicate delivery, so uniqueOnly is not needed.


8. Unit Testing

The mavericks-testing artifact. The test rule: disables lifecycle-awareness for subscriptions / makes the state store synchronous / skips debug checks / replaces the Main dispatcher with TestCoroutineDispatcher.

class MyViewModelTest {
    @get:Rule val rule = MavericksTestRule()
    private val repo = mockk<MyRepository>(relaxed = true)

    // ── execute success path ──
    @Test
    fun `loadData writes Success on success`() = runTest {
        coEvery { repo.fetchList() } returns listOf(Item("a"))

        val vm = MyViewModel(MyState(), repo)
        vm.loadData()

        withState(vm) { state ->
            assertTrue(state.items is Success)
            assertEquals(1, state.items()?.size)
        }
    }

    // ── execute failure path ──
    @Test
    fun `loadData writes Fail on failure`() = runTest {
        coEvery { repo.fetchList() } throws IOException("boom")

        val vm = MyViewModel(MyState(), repo)
        vm.loadData()

        withState(vm) { state ->
            assertTrue(state.items is Fail)
            assertEquals("boom", (state.items as Fail).error.message)
        }
    }

    // ── retainValue keeps old data on refresh failure ──
    @Test
    fun `refresh preserves previously successful items on failure`() = runTest {
        coEvery { repo.fetchList() } returns listOf(Item("old"))
        val vm = MyViewModel(MyState(), repo)
        vm.loadData()

        coEvery { repo.fetchList() } throws IOException("refresh failed")
        vm.refresh()

        withState(vm) { state ->
            assertTrue(state.items is Fail)
            assertEquals(listOf(Item("old")), state.items())   // Old data is still accessible
        }
    }

    // ── Publishing and consuming a one-shot event ──
    @Test
    fun `submit publishes Toast effect and clears it after consumption`() = runTest {
        val vm = MyViewModel(MyState(), repo)
        vm.submit()

        withState(vm) { state ->
            assertTrue(state.effect is Effect.ShowToast)
        }

        vm.consumeEffect()
        withState(vm) { state ->
            assertNull(state.effect)
        }
    }
}

JUnit 5: @RegisterExtension val ext = MavericksTestExtension().

Note: you do NOT need to write unit tests for reducer purity — MavericksTestRule disables debug checks by default, but the real app running in debug mode already double-runs every setState reducer and compares the results, so a single run of the app is enough to surface impure reducers. Re-verifying this in unit tests is redundant work.


9. MavericksRepository (experimental, not used in this project)

A pure-Kotlin state container with no Android dependencies. Only consider it when a pure-Kotlin / KMP shared module needs state management.

class WeatherRepository(scope: CoroutineScope, private val api: WeatherApi)
    : MavericksRepository<WeatherState>(
        initialState = WeatherState(),
        coroutineScope = scope,
        performCorrectnessValidations = BuildConfig.DEBUG,
    )

Requires -opt-in=com.airbnb.mvrx.InternalMavericksApi. Breaking change in 3.0: MavericksViewModelConfig.BlockExecutionsMavericksBlockExecutions.


10. Common Pitfalls

PitfallFix
State field typed as ArrayList / HashMap / ArrayMapDeclare it as the read-only List / Map interface
State field is a lambda / Function / KCallablePut behaviour on the VM; State should only hold data
Impure reducer → Impure reducer set on X! field changed from A to BReducers only read their input, never rely on external mutable state
Reading a new value right after setStateUse withState { }, or awaitState() in a suspend context
Blocking IO / heavy computation inside setStateReducers should only copy(); move heavy work into execute
UI flickers on refreshUse retainValue = State::prop
A long-running suspend inside onEach is unexpectedly cancelledcollectLatest semantics — move long work to execute
onEach callback crashes when accessing binding after the view is destroyedlifecycleScope.launch { repeatOnLifecycle(STARTED) { } }
Using whenStarted / whenResumed / whenCreatedDeprecated — always switch to repeatOnLifecycle
Passing deliveryMode to onEach inside a VM's initDoes not compile; DeliveryMode only exists inside MavericksView
Using UniqueOnly for primary rendering data → blank screen after rotationUse the default RedeliverOnStart for rendering
Hand-written UniqueOnly("nav")Use the uniqueOnly() helper
Forgetting consumeEffect() after an Effect is triggeredCauses duplicate triggers; clear it immediately after consumption
@PersistState applied to an Async fieldOnly annotate Parcelable / Serializable / primitives
Forgetting the Hilt @Binds @IntoMap @ViewModelKey registrationAdd it to the owning business module's ViewModelModule
Hilt + hand-written override create()Only use hiltMavericksViewModelFactory(); initialState may be overridden separately
Compose using vm.collectAsState()Not lifecycle-aware; switch to vm.collectAsStateWithLifecycle()
Import conflict between the two collectAsStateWithLifecycle extensionsAdd an import alias to one of them

Appendix A — Updating Immutable Map / Set

Pick one of the two options based on whether the module already depends on the Compose runtime. Do NOT add kotlinx-collections-immutable as a separate dependency.

Option A (module already depends on Compose): use the PersistentMap repackaged inside Compose

import androidx.compose.runtime.external.kotlinx.collections.immutable.PersistentMap
import androidx.compose.runtime.external.kotlinx.collections.immutable.persistentMapOf

data class XxxState(val items: PersistentMap<String, Item> = persistentMapOf()) : MavericksState

setState { copy(items = items.put(k, v)) }
setState { copy(items = items.remove(k)) }

// Batch update (structural sharing, most efficient)
setState { copy(items = items.mutate { m -> m[a] = x; m.remove(b) }) }

Benefits: immutability enforced by the type system, O(log n) structural-sharing updates, passes the Mavericks checks, and Compose treats it as @Immutable.

Option B (pure XML / module without Compose): stdlib Map extensions

Put these into the module's shared util file:

fun <K, V> Map<K, V>.copy(vararg pairs: Pair<K, V>): Map<K, V> =
    HashMap<K, V>(size + pairs.size).apply {
        putAll(this@copy)
        pairs.forEach { put(it.first, it.second) }
    }

fun <K, V> Map<K, V>.delete(vararg keys: K): Map<K, V> =
    HashMap<K, V>(size - keys.size).apply {
        [email protected]().filter { it.key !in keys }.forEach { put(it.key, it.value) }
    }

Usage:

setState { copy(items = items.copy("a" to 1, "b" to 2)) }
setState { copy(items = items.delete("a", "b")) }

Key point: the State field MUST be declared as the read-only Map<K, V> interface. The debug check only inspects the declared type, so even if the runtime instance is a HashMap, it will not be rejected.

How to Choose

SituationOption
The module depends on androidx.compose.runtimeA
Pure XML module, no ComposeB
The same module has both Compose and XMLA (for consistency)

Appendix B — Error Message Lookup

Error-message keywordCauseSection
State must be a data class!Not a data class§1
State property X must be a val, not a varField declared as var§1
You cannot use HashMap / ArrayList / ArrayMap for XField declared with a banned type§1 / Appendix A
You cannot use functions inside Mavericks stateField is a Function / KCallable§1
Impure reducer set on {VM}! {field} changed from X to YImpure reducer§2 / §10
X was mutated. State classes should be immutableA State instance was mutated after being submitted§1
duplicate subscription ...Two subscriptions share the same UniqueOnly id§6
Your state class must be publicWrong State visibility§1
Compose collectAsStateWithLifecycle unresolved / ambiguousImport collision between the two extensions of the same name§6