The view

A view renders a screen’s model and posts its intents. VIEView connects it to its ViewModel, and hands it the model and a way to talk back, never the ViewModel itself.

Rendering a screen

Counter.kt
@Composable
fun Counter(store: CounterStore) {
    VIEView(newVM = { CounterVM(store) }) { model -> (1)
        Column {
            Text("Count: ${model.count}")
            Button(onClick = { post(Intent.Increment) }) { Text("+") } (2)
        }
    }
}
1 newVM is only called the first time: the ViewModel is then kept by the current ViewModelStoreOwner, and the block recomposes with every new model.
2 post sends the intent to the ViewModel’s process.

VIEView takes:

Parameter Purpose

newVM

Creates the ViewModel, when the owner does not hold one yet. Its receiver is the owner’s CreationExtras, which gives access to createSavedStateHandle().

key

Tells apart several ViewModels of the same class, see ViewModel identity.

content

The view, called with the current model.

Inside content, this is a VIEViewScope<Intent, Signal>, which has exactly two members:

Member Purpose

fun post(intent: Intent)

Sends an intent up to the ViewModel.

val signalFlow: Flow<Signal>

The signals the view reacts to, usually through SignalEffect.

The view cannot read modelFlow, call update or reach viewModelScope: what it renders is the model it is given.

ViewModel identity

The ViewModel is kept by the ViewModelStoreOwner of the composition, like any viewModel { }: the activity or fragment on Android, or a navigation entry when the navigation library scopes ViewModels to its entries. Within one owner, it is identified by its class and by key:

  • Without key, every VIEView of the same ViewModel class shares one ViewModel.

  • With a key, each key gets its own ViewModel.

  • Two ViewModels of different classes never share one: the class is the one newVM is typed to return, so type it with the concrete class, not with VIEViewModel<…​>.

  • Two classes with the same simple name, under the same owner and with the same key, collide.

A key tells apart two screens of the same kind under one owner:

Row {
    VIEView(newVM = { CounterVM(store) }, key = "left") { model -> /* ... */ }
    VIEView(newVM = { CounterVM(store) }, key = "right") { model -> /* ... */ }
}

Leaving the composition does not clear a ViewModel: the owner keeps it until the owner itself is cleared. A ViewModel under a key that is no longer used stays in the store as well.

Screen arguments

newVM is called only when the owner holds no ViewModel for the class and key. An argument it captures is read once, and a later value is ignored:

@Composable
fun Task(taskId: Uuid) {
    VIEView(newVM = { TaskVM(taskId) }, key = taskId.toString()) { model -> (1)
        Task(model)
    }
}
1 Without key, a second taskId would keep the ViewModel of the first one.

An argument that changes while the screen stays in the composition needs a key derived from it, as above, so that each value gets its own ViewModel. Otherwise, give each screen its own owner, such as a navigation entry. newVM is not a composable function: it cannot read a State or a CompositionLocal.

On the JVM, on Android and on iOS, a ViewModel declared as a local class needs a key: without one, VIEView throws an IllegalArgumentException, "Local and anonymous classes can not be ViewModels". Web targets do not throw.

Reacting to signals

SignalEffect collects signalFlow as long as the view is in the composition, and calls its block with each signal:

SignalEffect { signal ->
    when (signal) {
        is Signal.Saved -> snackbar.showSnackbar("Saved")
        is Signal.Error -> snackbar.showSnackbar(signal.message)
    }
}

The block is a suspend function, so it can call showSnackbar or animate directly. Write the when without an else, so that every signal of the contract is handled.

Signals are handled one at a time. showSnackbar returns only once the snackbar is dismissed, so the signals emitted meanwhile wait behind it. When a signal must not wait, such as a navigation, launch the slow call from a coroutine of the view:

val scope = rememberCoroutineScope()
SignalEffect { signal ->
    when (signal) {
        is Signal.Saved -> scope.launch { snackbar.showSnackbar("Saved") }
        is Signal.Error -> snackbar.showSnackbar(signal.message)
    }
}

The block is always the one of the latest composition: it can read the current state of the view. A view whose contract has signals needs a SignalEffect, since nothing else collects them. The collection follows the composition, not the Android lifecycle: it goes on while the view is in the composition, in the background too.

Views as extensions

Splitting a screen in two functions separates the wiring from the layout:

Counter.kt
@Composable
fun Counter(store: CounterStore) { (1)
    VIEView(newVM = { CounterVM(store) }) { model ->
        Counter(model)
    }
}

@Composable
fun VIEViewScope<Intent, Signal>.Counter(model: Model) { (2)
    val snackbar = remember { SnackbarHostState() }
    SignalEffect { signal ->
        when (signal) {
            is Signal.Saved -> snackbar.showSnackbar("Saved")
            is Signal.Error -> snackbar.showSnackbar(signal.message)
        }
    }
    Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
        Column(Modifier.padding(padding)) {
            Text("Count: ${model.count}")
            Button(onClick = { post(Intent.Increment) }) { Text("+") }
            Button(
                onClick = { post(Intent.Save) },
                enabled = !model.saving,
            ) { Text("Save") }
        }
    }
}
1 The wiring: creates the ViewModel and passes the model on.
2 The layout: an extension on VIEViewScope, so it only sees the model, post and the signals.

The layout needs nothing but a VIEViewScope and a model, so it renders in a preview or a test without a ViewModel. Only the wiring names the ViewModel, so the ViewModel can be internal to its feature module. The screens of the demo application are all written this way.

Previews and Testing a view show how to render the layout without its ViewModel.