Tartlet
0.4.0indexedSimplifies Compose UI state and event handling with immutable ViewStore snapshots, enabling child Composables to call actions directly, ease previews, and render or handle multiple states/events.
Simplifies Compose UI state and event handling with immutable ViewStore snapshots, enabling child Composables to call actions directly, ease previews, and render or handle multiple states/events.

Tartlet is a helper library for Compose Multiplatform.
Key benefits:
implementation("io.yumemi:tartlet:<latest-release>")
Define a data class to represent your UI state:
data class CounterState(val count: Int)
Define a sealed interface for one-time UI events:
sealed interface CounterEvent {
data class ShowToast(val message: String) : CounterEvent
}
Typically implemented by a ViewModel:
Note: Tartlet itself does not have a state persistence feature. To maintain the screen state, the
ViewModelmust serve as theStore.
An immutable snapshot of UI state that provides methods to render state values, execute actions, and handle events:
@Composable
fun CounterScreen(
viewStore: ViewStore<CounterState, CounterEvent, CounterViewModel> = rememberViewStore { viewModel() },
) {
Column {
Text("Count: ${viewStore.state.count}")
// Call ViewModel method
Button(onClick = { viewStore.action { increment() } }) {
Text("Increment")
}
Button(onClick = { viewStore.action { decrement() } }) {
Text("Decrement")
}
}
viewStore.handle<CounterEvent.ShowToast> { event ->
// Show toast..
}
}
Passing ViewStore instances to child Composables eliminates the need to hoist actions:
@Composable
fun },
) {
CounterContent(viewStore = viewStore)
viewStore.handle<CounterEvent.ShowToast> { event ->
}
}
{
Column {
Text()
Button(onClick = { viewStore.action { increment() } }) {
Text()
}
Button(onClick = { viewStore.action { decrement() } }) {
Text()
}
}
}
Specify Nothing for the event type.
class CounterViewModel : ViewModel(), Store<CounterState, Nothing> {
private val _state = MutableStateFlow<CounterState>(CounterState(count = 0))
override val state = _state.asStateFlow()
// No need to override event property when using Nothing type
fun increment() { ... }
{ ... }
}
When using sealed interfaces for multiple states, use ViewStore.render() to render different UI based on the current state type:
The ViewStore's state type is automatically narrowed within the render block, allowing the casted ViewStore to be passed to child Composables:
Handle the parent event type and use when expressions to process each event type:
Create an instance of ViewStore directly with the target state.
@Preview
@Composable
fun CounterScreenLoadingPreview() {
MyApplicationTheme {
CounterScreen(
viewStore = ViewStore {
CounterState.Loading
},
)
}
}
Tips: This can also be used to mock dependencies in unit tests for composables.
This allows UI development with only state, without requiring a ViewModel.
class CounterViewModel : ViewModel(), Store<CounterState, CounterEvent> { // Inherits Store
private val _state = MutableStateFlow<CounterState>(CounterState(count = 0))
override val state = _state.asStateFlow() // Override state property
private val _event = MutableSharedFlow<CounterEvent>()
override val event = _event.asSharedFlow() // Override event property
fun increment() {
_state.update { it.copy(count = it.count + 1) }
}
fun decrement() {
if (0 < _state.value.count) {
_state.update { it.copy(count = it.count - 1) }
} else {
viewModelScope.launch { _event.emit(CounterEvent.ShowToast("Cannot decrement below zero.")) }
}
}
}
sealed interface CounterState {
data object Loading : CounterState
data class Stable(val count: Int) : CounterState
data class Error(val message: String) : CounterState
}
fun CounterScreen(
viewStore: ViewStore<CounterState, Nothing, CounterViewModel> = rememberViewStore { viewModel() },
) {
viewStore.render<CounterState.Loading> {
CircularProgressIndicator()
}
viewStore.render<CounterState.Stable> {
Column {
Text("Count: ${state.count}") // state is cast to CounterState.Stable
Button(onClick = { action { increment() } }) {
Text("Increment")
}
Button(onClick = { action { decrement() } }) {
Text("Decrement")
}
}
}
viewStore.render<CounterState.Error> {
Text("Error: ${state.message}", color = Color.Red) // state is cast to CounterState.Error
}
}
fun CounterScreen(
viewStore: ViewStore<CounterState, Nothing, CounterViewModel> = rememberViewStore { viewModel() },
) {
viewStore.render<CounterState.Loading> {
// ...
}
viewStore.render<CounterState.Stable> {
CounterContent(viewStore = this) // Pass casted ViewStore to child
}
viewStore.render<CounterState.Error> {
// ...
}
}
private fun CounterContent(
viewStore: ViewStore<CounterState.Stable, Nothing, CounterViewModel> // state is cast to CounterState.Stable
) {
Column {
Text("Count: ${viewStore.state.count}")
// ...
}
}
sealed interface CounterEvent {
data class ShowToast(val message: String) : CounterEvent
data class NavigateToDetail(val id: Int) : CounterEvent
data object Refresh : CounterEvent
}
fun CounterScreen(
viewStore: ViewStore<CounterState, CounterEvent, CounterViewModel> = rememberViewStore { viewModel() },
) {
// ...
viewStore.handle<CounterEvent> { event ->
when (event) {
is CounterEvent.ShowToast -> {
// Show toast with event.message
}
is CounterEvent.NavigateToDetail -> {
// Navigate to detail screen with event.id
}
is CounterEvent.Refresh -> {
// Refresh the screen
}
}
}
}
Surfaced from shared tags and platforms — no rankings paid for.