Compose Integration
The anchor-compose module provides Jetpack Compose bindings for Anchor. It connects the Anchor state machine to the Compose lifecycle, giving you type-safe state observation, action dispatching, and signal handling — all scoped to a ViewModel.
Why a separate module? Keeping Compose dependencies out of the core anchor module means your shared business logic stays free of UI framework dependencies. This is especially important for Kotlin Multiplatform projects where not every target uses Compose.
Installation
Note
anchor-compose transitively includes the core anchor module, so you don't need to add both.
RememberAnchor
RememberAnchor is the primary composable that sets up an Anchor-powered screen. It creates a ViewModel-scoped Anchor instance, collects state, and provides the necessary CompositionLocals for anchor() and HandleSignal.
@Composable
fun CounterScreen() {
RememberAnchor(scope = { counterAnchor() }) {
val count = collectState { it.count }
Text("Count: $count")
Button(onClick = anchor(CounterAnchor::increment)) {
Text("+")
}
}
}
Parameters
| Parameter | Description |
|---|---|
scope |
Factory that creates the Anchor instance (called once per ViewModel) |
customKey |
Optional key for ViewModel storage (defaults to the ViewState class name) |
content |
Composable block receiving AnchorStateScope<S> |
Lifecycle
- On first composition,
RememberAnchorcreates aContainerViewModelthat holds theAnchorRuntime - The Anchor's
initblock runs once, andsubscriptionsare set up - State is collected via
collectAsStateWithLifecycle()onMain.immediate - The ViewModel survives configuration changes — state is retained automatically
Observing State
Inside RememberAnchor, you have access to AnchorStateScope<S> which provides two ways to observe state:
state — Full state access
Any change to the state triggers recomposition of the entire content block.
collectState — Granular recomposition
RememberAnchor(scope = { counterAnchor() }) {
val count = collectState { it.count }
val isLoading = collectState { it.isLoading }
// Only recomposes when the selected value changes,
// not when other state fields change.
}
collectState applies a selector function and only triggers recomposition when the selected value changes. Prefer this for screens with many state fields.
Dispatching Actions
The anchor() composable creates type-safe callbacks from Anchor action functions. It supports 0 to 3 parameters:
// No parameters — returns () -> Unit
Button(onClick = anchor(CounterAnchor::increment)) {
Text("+")
}
// One parameter — returns (I) -> Unit
TextField(onValueChange = anchor(ConfigAnchor::updateText))
// Two parameters — returns (I, O) -> Unit
CustomSlider(onChange = anchor(SettingsAnchor::updateRange))
Actions are executed asynchronously on Dispatchers.Default within the ViewModel's coroutine scope.
Handling Signals
Signals are one-time events (navigation, snackbars, toasts) that shouldn't persist in state. Use HandleSignal to react to them:
RememberAnchor(scope = { counterAnchor() }) {
HandleSignal<CounterSignal> { signal ->
when (signal) {
CounterSignal.Increment -> snackbarHostState.showSnackbar("Incremented!")
CounterSignal.Decrement -> snackbarHostState.showSnackbar("Decremented!")
}
}
// ... UI content
}
HandleSignal uses LaunchedEffect internally — it respects the composable lifecycle and automatically stops collecting when the composable leaves the composition.
Compose Previews
Use PreviewAnchor to provide static state for @Preview composables without needing a full Anchor setup:
@Preview
@Composable
fun CounterPreview() {
PreviewAnchor(state = CounterState(count = 42)) {
val count = collectState { it.count }
Text("Count: $count")
}
}
PreviewAnchor wraps the state in an AnchorStateScope so your content composable works identically to production. Actions dispatched via anchor() become no-ops in previews.
Full Example
Putting it all together:
// State
data class CounterState(
val count: Int = 0,
) : ViewState
// Signals
sealed interface CounterSignal : Signal {
data object Increment : CounterSignal
data object Decrement : CounterSignal
}
// Anchor factory
typealias CounterAnchor = Anchor<EmptyEffect, CounterState, Nothing>
fun RememberAnchorScope.counterAnchor(): CounterAnchor =
create(initialState = ::CounterState, effectScope = { EmptyEffect })
// Actions
suspend fun CounterAnchor.increment() {
reduce { copy(count = count + 1) }
post { CounterSignal.Increment }
}
suspend fun CounterAnchor.decrement() {
reduce { copy(count = count - 1) }
post { CounterSignal.Decrement }
}
// UI
@Composable
fun CounterScreen(snackbarHostState: SnackbarHostState) {
RememberAnchor(scope = { counterAnchor() }) {
HandleSignal<CounterSignal> { signal ->
val message = when (signal) {
CounterSignal.Increment -> "Incremented"
CounterSignal.Decrement -> "Decremented"
}
snackbarHostState.showSnackbar(message)
}
val count = collectState { it.count }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = count.toString(),
style = MaterialTheme.typography.headlineMedium,
)
Row {
Button(onClick = anchor(CounterAnchor::decrement)) { Text("-") }
Button(onClick = anchor(CounterAnchor::increment)) { Text("+") }
}
}
}
}
// Preview
@Preview
@Composable
fun CounterPreview() {
PreviewAnchor(state = CounterState(count = 10)) {
val count = collectState { it.count }
Text("Count: $count")
}
}