
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:
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.")) }
}
}
}
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 CounterScreen(
viewStore: ViewStore<CounterState, CounterEvent, CounterViewModel> = rememberViewStore { viewModel() },
) {
CounterContent(viewStore = viewStore) // Pass ViewStore to child
viewStore.handle<CounterEvent.ShowToast> { event ->
// Show toast..
}
}
@Composable
private fun CounterContent(
viewStore: ViewStore<CounterState, CounterEvent, CounterViewModel>
) {
Column {
Text("Count: ${viewStore.state.count}")
// No need to hoist actions
Button(onClick = { viewStore.action { increment() } }) {
Text("Increment")
}
Button(onClick = { viewStore.action { decrement() } }) {
Text("Decrement")
}
}
}
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() { ... }
fun decrement() { ... }
}
When using sealed interfaces for multiple states, use ViewStore.render() to render different UI based on the current state type:
sealed interface CounterState {
data object Loading : CounterState
data class Stable(val count: Int) : CounterState
data class Error(val message: String) : CounterState
}
@Composable
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
}
}
The ViewStore's state type is automatically narrowed within the render block, allowing the casted ViewStore to be passed to child Composables:
@Composable
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> {
// ...
}
}
@Composable
private fun CounterContent(
viewStore: ViewStore<CounterState.Stable, Nothing, CounterViewModel> // state is cast to CounterState.Stable
) {
Column {
Text("Count: ${viewStore.state.count}")
// ...
}
}
Handle the parent event type and use when expressions to process each event type:
sealed interface CounterEvent {
data class ShowToast(val message: String) : CounterEvent
data class NavigateToDetail(val id: Int) : CounterEvent
data object Refresh : CounterEvent
}
@Composable
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
}
}
}
}
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.
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:
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.")) }
}
}
}
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 CounterScreen(
viewStore: ViewStore<CounterState, CounterEvent, CounterViewModel> = rememberViewStore { viewModel() },
) {
CounterContent(viewStore = viewStore) // Pass ViewStore to child
viewStore.handle<CounterEvent.ShowToast> { event ->
// Show toast..
}
}
@Composable
private fun CounterContent(
viewStore: ViewStore<CounterState, CounterEvent, CounterViewModel>
) {
Column {
Text("Count: ${viewStore.state.count}")
// No need to hoist actions
Button(onClick = { viewStore.action { increment() } }) {
Text("Increment")
}
Button(onClick = { viewStore.action { decrement() } }) {
Text("Decrement")
}
}
}
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() { ... }
fun decrement() { ... }
}
When using sealed interfaces for multiple states, use ViewStore.render() to render different UI based on the current state type:
sealed interface CounterState {
data object Loading : CounterState
data class Stable(val count: Int) : CounterState
data class Error(val message: String) : CounterState
}
@Composable
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
}
}
The ViewStore's state type is automatically narrowed within the render block, allowing the casted ViewStore to be passed to child Composables:
@Composable
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> {
// ...
}
}
@Composable
private fun CounterContent(
viewStore: ViewStore<CounterState.Stable, Nothing, CounterViewModel> // state is cast to CounterState.Stable
) {
Column {
Text("Count: ${viewStore.state.count}")
// ...
}
}
Handle the parent event type and use when expressions to process each event type:
sealed interface CounterEvent {
data class ShowToast(val message: String) : CounterEvent
data class NavigateToDetail(val id: Int) : CounterEvent
data object Refresh : CounterEvent
}
@Composable
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
}
}
}
}
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.