
State management framework simplifies code with one-way data flow, declarative style, and reduced side effects. Inspired by Flux, supports multi-platform usage, and state-action relationships.
The Tart logo (doc/logo.svg) is licensed under CC0 1.0 Universal. You may use it freely for any purpose without attribution. See doc/LOGO_LICENSE for details.
[!IMPORTANT] We are considering moving this repository to a different organization. As part of that move, the library group name may change.
Tart is a state management framework for Kotlin Multiplatform.
Key benefits:
The architecture is inspired by Flux and is as follows:
Tart works especially well when a feature has multiple explicit UI or business states and the transition rules between them are important.
By combining Kotlin sealed class/sealed interface with Tart's state machine DSL, you can keep each state's enter{}, action{}, exit{}, and error{} behavior close together and make the transition rules easy to follow.
Tart currently focuses on the core pieces of state management: explicit state transitions, coroutine-based asynchronous work, state persistence, and middleware-driven extensions such as logging and inter-store messaging. It keeps surrounding helper layers intentionally small, so dependencies and feature composition can stay in ordinary Kotlin, while the core Store logic remains portable across platforms.
implementation("io.yumemi.tart:tart-core:<latest-release>")Let’s take a simple counter app as an example. First, define the State and Action classes.
data class CounterState(val count: Int) : State
sealed interface CounterAction : Action {
data object Increment : CounterAction
data object Decrement : CounterAction
}Create a Store using the Store{} DSL and an initial State.
val store: Store<CounterState, CounterAction, Nothing> = Store(CounterState(count = 0)) {}
// or, use the initialState() specification
val store: Store<CounterState, CounterAction, Nothing> = Store {
initialState(CounterState(count = 0))
}Define how Actions change State using the state{} and action{} blocks.
Specify the resulting State with nextState().
If you want to compute and return the next state inside a block, use nextStateBy { ... }.
If neither nextState() nor nextStateBy() is specified, the current state remains unchanged.
val store: Store<CounterState, CounterAction, Nothing> = Store(CounterState(count = 0)) {
state<CounterState> {
action<CounterAction.Increment> {
nextState(state.copy(count = state.count + 1))
}
action<CounterAction.Decrement> {
if (0 < state.count) {
nextState(state.copy(count = state.count - 1))
} else {
// do not change State
}
}
}
}For conditional or complex updates, nextStateBy {} can make the computation easier to read.
nextStateBy {
// ...
val newCount = ...
state.copy(count = newCount)
}The Store setup is complete. Keep the store instance in a ViewModel (or similar).
Dispatch an Action from the UI using the Store's dispatch() method.
// example in Compose
Button(
onClick = { store.dispatch(CounterAction.Increment) },
) {
Text(text = "increment")
}The new State is exposed via the Store's .state (StateFlow), so render it in the UI.
Define your Event class and set it as the third type parameter of Store.
sealed interface CounterEvent : Event {
data class ShowToast(val message: String) : CounterEvent
data object NavigateToNextScreen : CounterEvent
}val store: Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {
// ...
}In an action{} block, specify an Event with event().
action<CounterAction.Decrement> {
if (0 < state.count) {
nextState(state.copy(count = state.count - 1))
} else {
event(CounterEvent.ShowToast("Can not Decrement.")) // raise event
}
}Collect the Store's .event (Flow) in the UI and handle it.
Have repositories and UseCase classes available in your store creation scope and use them inside action{} blocks.
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {
state<CounterState> {
action<CounterAction.Load> {
val count = counterRepository.get() // load
nextState(state.copy(count = count))
}
action<CounterAction.Increment> {
val count = state.count + 1
counterRepository.set(count) // save
nextState(state.copy(count = count))
}
// ...
}
}
// or, define a Store class using delegation
class CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> by Store(
initialState = CounterState(count = 0),
setup = {
state<CounterState> {
// ...
}
},
)Processing other than changing the State may be defined as functions, as they tend to become complex and lengthy.
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {
// define as a function
suspend fun loadCount(): Int {
return counterRepository.get()
}
state<CounterState> {
action<CounterAction.Load> {
nextState(state.copy(count = loadCount())) // call the function
}
// ...You may also define them as extension functions of State or Action.
In the previous examples, the State was single. If you need multiple States (for example, a UI during data loading), define them explicitly.
sealed interface CounterState : State {
data object Loading : CounterState
data class Main(val count: Int) : CounterState
}fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState.Loading> { // for Loading state
action<CounterAction.Load> {
val count = counterRepository.get()
nextState(CounterState.Main(count = count)) // transition to Main state
}
}
state<CounterState.Main> { // for Main state
action<CounterAction.Increment> {
// ...In this example, the CounterAction.Load action needs to be issued from the UI when the application starts.
If you want to run logic when a State starts, use the enter{} block (similarly, you can use the exit{} block if necessary).
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState.Loading> {
enter {
val count = counterRepository.get()
nextState(CounterState.Main(count = count)) // transition to Main state
}
}
state<CounterState.Main> {
action<CounterAction.Increment> {
// ...The state diagram is as follows:
This framework's architecture can be easily visualized using state diagrams. It would be a good idea to document it and share it with your development team.
You can also target a parent sealed type in state<...>{} or action<...>{} when the same handler should apply across multiple variants.
val store: Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState> {
enter {
// runs for all CounterState variants
}
}
state<CounterState.Main> {
action<CounterAction> {
when (action) {
CounterAction.Increment -> {
// ...
}
CounterAction.Decrement -> {
// ...
}
CounterAction.Load -> Unit
}
}
}
}Handler selection is first-match. If both broad and specific handlers can match, the one registered earlier is used. In practice, place broader handlers after more specific ones.
state<CounterState.Loading> {
action<CounterAction.Load> {
// specific handler
}
}
state<CounterState> {
action<CounterAction> {
// broader fallback handler
}
}If you prepare a State for error display and handle the error in the enter{} block, it will be as follows:
sealed interface CounterState : State {
// ...
data class Error(val error: Exception) : CounterState
}val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
state<CounterState.Loading> {
enter {
try {
val count = counterRepository.get()
nextState(CounterState.Main(count = count))
} catch (e: Exception) {
nextState(CounterState.Error(error = e))
}
}
}
}This works, but you can also handle errors with the error{} block.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
state<CounterState.Loading> {
enter {
// no error handling code
val count = counterRepository.get()
nextState(CounterState.Main(count = count))
}
// more specific exceptions should be placed first
error<IllegalStateException> {
// ...
nextState(CounterState.Error(error = error))
}
// more general exception handlers should come last
error<Exception> {
// ...
nextState(CounterState.Error(error = error))
}
}
}Errors can be caught not only in the enter{} block but also in the action{} and exit{} blocks.
In other words, your business logic errors can be handled in the error{} block.
On the other hand, uncaught errors in the entire Store (such as system errors) can be handled with the exceptionHandler() specification:
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
exceptionHandler(...)
}You can also create an ExceptionHandler instance with the ExceptionHandler() factory function.
You can use launch{} in both enter{} and action{} blocks to run asynchronous work and update State (or emit Events).
This is useful for integrating long-running tasks such as flow collection, network calls, and background processing:
state<MyState.Active> {
enter {
// launch a coroutine that lives as long as this state is active
launch {
// collect from an external data source
dataRepository.observeData().collect { newData ->
// update state with the new data in a transaction
transaction {
nextState(state.copy(data = newData))
}
}
}
}
}You can also start asynchronous work from an action:
state<MyState.Active> {
action<MyAction.Refresh> {
launch {
// state updates in launch must be done in transaction{} block
transaction {
nextState(state.copy(isRefreshing = true))
}
dataRepository.refresh()
transaction {
nextState(state.copy(isRefreshing = false))
}
}
}
}This pattern lets your Store react to external data changes automatically, such as database updates, user preference changes, or network events.
Coroutines started by launch{} are automatically cancelled when the State changes to a different State, making it easy to manage resources and subscriptions.
In action{}, launch{} is tied to the State active at action start.
If you want lightweight coordination and explicit cancellation for coroutines launched from an action handler, set the control directly on launch(...):
val store = Store(MyState.Active()) {
val searchLane = object {}
val submitLane = object {}
state<MyState.Active> {
action<MyAction.QueryChanged> {
nextState(state.copy(query = action.query, isLoading = true))
launch(control = LaunchControl.Replace(searchLane)) {
delay(300)
val result = repository.search(action.query)
transaction {
nextState(
state.copy(
result = result,
isLoading = false,
),
)
}
}
}
action<MyAction.ClearQuery> {
cancelLaunch(searchLane)
nextState(
state.copy(
query = "",
result = emptyList(),
isLoading = false,
),
)
}
action<MyAction.Submit> {
launch(control = LaunchControl.DropNew(submitLane)) {
submit()
}
}
}
}LaunchControl.Replace(key) cancels the previous tracked launch in the same lane before starting the next one.
LaunchControl.DropNew(key) ignores new launches while tracked work in the same lane is still active.
LaunchControl.Concurrent keeps the default behavior and runs launches independently.
cancelLaunch(key) only affects coroutines started from action { launch { ... } } in the current active state's runtime that use tracked controls such as LaunchControl.Replace(...) and LaunchControl.DropNew(...). It does not cancel LaunchControl.Concurrent launches or enter { launch { ... } }.
The Store operates using Coroutines, and the default CoroutineContext is EmptyCoroutineContext + Dispatchers.Default.
Specify it to align the Store's Coroutines lifecycle with another context or to change the execution thread.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(...)
}If you don’t use an auto-disposed scope like ViewModel's viewModelScope or Compose's rememberCoroutineScope(), call Store's .dispose() method explicitly when the Store is no longer needed.
Then, processing of all Coroutines will stop.
You can specify the execution thread (CoroutineDispatchers) in enter{}, exit{}, action{}, error{}, and launch{} blocks, allowing you to locally control which thread each specific operation runs on.
enter(Dispatchers.Default) {
// work on CPU thread..
launch(Dispatchers.IO) {
// This code runs on IO thread
val updates = dataRepository.observeUpdates()
updates.collect { newData ->
// ...
}
}
}Alternatively, you can use Coroutines' withContext().
enter {
withContext(Dispatchers.Default) {
// work on CPU thread..
withContext(Dispatchers.IO) {
// This code runs on IO thread
launch {
val updates = dataRepository.observeUpdates()
updates.collect { newData ->
// ...
}
}
}
}
}You can prepare a StateSaver to automatically handle State persistence:
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
stateSaver(...)
}You can also create a StateSaver instance with the StateSaver() factory function.
By default, Tart clears already queued actions when the store exits the current state and enters a different state variant.
To keep queued actions across state exits, set pendingActionPolicy(PendingActionPolicy.KEEP).
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
pendingActionPolicy(PendingActionPolicy.KEEP)
}Regardless of the configured PendingActionPolicy, you can still discard already queued actions at a specific point by calling clearPendingActions() inside enter{}, action{}, exit{}, error{}, or inside transaction{} from a launched coroutine.
The body of Store{} is ordinary Kotlin code, so you can use control flow such as if and when when specifying Store configuration.
fun CounterStore(
logExceptions: Boolean,
): Store<CounterState, CounterAction, Nothing> = Store {
initialState(CounterState(count = 0))
if (logExceptions) {
exceptionHandler(ExceptionHandler.Log)
}
}On platforms where Store's .state (StateFlow) and .event (Flow) cannot be consumed directly (e.g., iOS), use .collectState() and .collectEvent().
If the State or Event changes, you will be notified through these callbacks.
You can use Store's .state (StateFlow), .event (Flow), and .dispatch() directly, but we provide a mechanism for Compose.
implementation("io.yumemi.tart:tart-compose:<latest-release>")Create an instance of the ViewStore from a Store using the rememberViewStore() function.
For example, if you have a Store in ViewModels, it would look like this:
fun CounterStore(
coroutineContext: CoroutineContext,
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(coroutineContext)
}@HiltViewModel
class CounterViewModel @Inject constructor(
counterRepository: CounterRepository,
) : ViewModel() {
val store = CounterStore(
coroutineContext = viewModelScope.coroutineContext,
counterRepository = counterRepository,
)
}@Composable
fun CounterScreen(
// create an instance of ViewStore
viewStore: ViewStore<CounterState, CounterAction, CounterEvent> = rememberViewStore {
hiltViewModel<CounterViewModel>().store
},
) {
// ...
// pass the ViewStore instance to lower components if necessary
YourComposable(
viewStore = viewStore,
)
}Alternatively, you can use the Store{} DSL directly in the ViewModel as follows, but note that in this case you need tests for CounterViewModel, and sharing a Store across multiple platforms becomes harder.
@HiltViewModel
class MainViewModel @Inject constructor(
counterRepository: CounterRepository,
) : ViewModel() {
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(viewModelScope.coroutineContext)
}
}You can create a ViewStore instance without using ViewModel as shown below:
fun CounterStore(
coroutineContext: CoroutineContext,
stateSaver: StateSaver<CounterState>,
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(coroutineContext)
stateSaver(stateSaver)
}@Composable
fun CounterScreen(
viewStore: ViewStore<CounterState, CounterAction, CounterEvent> = rememberViewStore {
CounterStore(
coroutineContext = rememberCoroutineScope().coroutineContext, // or, specify the autoDispose option in rememberViewStore {}
stateSaver = rememberStateSaver(), // state persistence during screen rotation, etc.
counterRepository = CounterRepositoryImpl(),
)
},
) {
// ...
}If you inject instances such as Repository using a DI library, it is useful to create a class like the following.
class CounterStoreContainer(
private val counterRepository: CounterRepository,
) {
fun build(coroutineContext: CoroutineContext, stateSaver: StateSaver<CounterState>): Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(coroutineContext)
stateSaver(stateSaver)
}
}If there’s a single State, just use ViewStore's .state property.
Text(
text = viewStore.state.count.toString(),
)If there are multiple States, use the .render() method for the target State.
viewStore.render<CounterState.Main> {
Text(
text = state.count.toString(),
)
}When drawing the UI, if it does not match the target State, the .render() will not be executed.
Therefore, you can define components for each State side by side.
viewStore.render<CounterState.Loading> {
Text(
text = "loading..",
)
}
viewStore.render<CounterState.Main> {
Text(
text = state.count.toString(),
)
}If you use lower components in the render() block, pass its instance.
viewStore.render<CounterState.Main> {
YourComposable(
viewStore = this, // ViewStore instance for CounterState.Main
)
}@Composable
fun YourComposable(
// Main state is confirmed
viewStore: ViewStore<CounterState.Main, CounterAction, CounterEvent>,
) {
Text(
text = viewStore.state.count.toString()
)
}Use ViewStore's .dispatch() with the target Action.
Button(
onClick = { viewStore.dispatch(CounterAction.Increment) },
) {
Text(
text = "increment"
)
}Use ViewStore's .handle() with the target Event.
viewStore.handle<CounterEvent.ShowToast> { event ->
// do something..
}In the above example, you can also subscribe to the parent Event type.
viewStore.handle<CounterEvent> { event ->
when (event) {
is CounterEvent.ShowToast -> // do something..
is CounterEvent.GoBack -> // do something..
// ...Create an instance of ViewStore directly with the target State.
@Preview
@Composable
fun LoadingPreview() {
MyApplicationTheme {
CounterScreen(
viewStore = ViewStore(
state = CounterState.Loading,
),
)
}
}Therefore, if you prepare only the State, it is possible to develop the UI.
You can create extensions that work with the Store.
To do this, create a class that implements the Middleware interface and override the necessary methods.
class YourMiddleware<S : State, A : Action, E : Event> : Middleware<S, A, E> {
override suspend fun afterStateChange(state: S, prevState: S) {
// do something..
}
}Apply the created Middleware as follows:
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
// add Middleware instance
middleware(YourMiddleware())
// or, implement Middleware directly here
middleware(
object : Middleware<CounterState, CounterAction, CounterEvent> {
override suspend fun afterStateChange(state: CounterState, prevState: CounterState) {
// do something..
}
},
)
// add multiple Middlewares
middleware(..., ...)
}Note that State is read-only in Middleware.
You can also create a Middleware instance with the Middleware() factory function.
Middleware methods are suspending functions. The Store waits for middleware processing to complete before proceeding.
When multiple middleware instances are registered, Tart invokes them concurrently by default.
If middleware must run one by one in registration order, set middlewareExecutionPolicy(MiddlewareExecutionPolicy.IN_REGISTRATION_ORDER).
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
middlewareExecutionPolicy(MiddlewareExecutionPolicy.IN_REGISTRATION_ORDER)
}Because a long-running method can block the Store, run heavy work in a separate CoroutineScope.
In the next section, we introduce built-in Middleware.
The source code is the :tart-logging and :tart-message modules in this repository, so you can use it as a reference for your Middleware implementation.
Middleware for logging Store operations.
implementation("io.yumemi.tart:tart-logging:<latest-release>")Apply the simpleLogging() middleware factory function to your Store to log all actions, events, state changes, and errors.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
middleware(simpleLogging())
}You can create custom logging middleware by extending the LoggingMiddleware class if you need more control over the logging behavior.
middleware(
object : LoggingMiddleware<CounterState, CounterAction, CounterEvent>(
logger = YourLogger() // specify your logger
) {
// override methods
override suspend fun beforeStateEnter(state: CounterState) {
log(...)
}
},
)Middleware for sending messages between Stores.
implementation("io.yumemi.tart:tart-message:<latest-release>")First, prepare classes for messages.
sealed interface MainMessage : Message {
data object LoggedOut : MainMessage
data class CommentLiked(val commentId: Int) : MainMessage
}Apply the receiveMessages() middleware factory function to the Store that receives messages.
val myPageStore: Store<MyPageState, MyPageAction, MyPageEvent> = Store {
// ...
middleware(
receiveMessages { message ->
when (message) {
MainMessage.LoggedOut -> dispatch(MyPageAction.doLogoutProcess)
// ...
}
}
)
}Define the message() specification at any point in the Store that sends messages.
val mainStore: Store<MainState, MainAction, MainEvent> = Store {
// ...
state<MainState.LoggedIn> { // leave the logged-in state
exit {
message(MainMessage.LoggedOut)
}
}
}Store{} DSL accepts an overrides block that is applied after the main setup block.
Use it when you want to override Store configuration.
fun CounterStore(
overrides: Overrides<CounterState, CounterAction, Nothing> = {},
): Store<CounterState, CounterAction, Nothing> = Store(
initialState = CounterState(count = 0),
overrides = overrides,
) {
middleware(AppLoggingMiddleware())
state<CounterState> {
// ...
}
}
val store = CounterStore()
val testStore = CounterStore(
overrides = {
clearMiddlewares()
exceptionHandler(ExceptionHandler.Log)
},
)Inside overrides block, you can use these APIs:
coroutineContext(...)stateSaver(...)exceptionHandler(...)middleware(...)clearMiddlewares()replaceMiddlewares(...)pendingActionPolicy(...)middlewareExecutionPolicy(...)Typical uses are:
In larger projects, it can be useful to wrap Store{} DSL in a project-specific AppStore{} that applies app-wide defaults in one place.
This lets you centralize shared Store configuration.
fun <S : State, A : Action, E : Event> AppStore(
initialState: S,
overrides: Overrides<S, A, E> = {},
setup: Setup<S, A, E>,
): Store<S, A, E> = Store(
initialState = initialState,
overrides = overrides,
) {
// shared Store configuration
middleware(AppLoggingMiddleware())
exceptionHandler(AppExceptionHandler)
setup()
}A feature Store can then focus on its own state transitions and actions:
fun CounterStore(
counterRepository: CounterRepository,
overrides: Overrides<CounterState, CounterAction, CounterEvent> = {},
): Store<CounterState, CounterAction, CounterEvent> = AppStore( // use AppStore{}
initialState = CounterState(count = 0),
overrides = overrides,
) {
state<CounterState> {
// ...
}
}
val store = CounterStore(counterRepository = counterRepository)
val testStore = CounterStore(
counterRepository = counterRepository,
overrides = {
clearMiddlewares()
// ...
},
)Add :tart-test to your test source set to use Tart's test helpers such as createRecorder() and dispatchAndWait().
commonTestImplementation("io.yumemi.tart:tart-test:<latest-release>")For most Store tests, use createRecorder() to create and attach the default StoreRecorder, then assert recorded state and event history.
@Test
fun counterStore_recordsStatesAndEvents() = runTest {
// Given
val store = CounterStore(...)
val recorder = store.createRecorder()
// When
store.dispatchAndWait(CounterAction.Increment) // wait until the dispatched action completes
// Then
assertEquals(
listOf(
CounterState(count = 0),
CounterState(count = 1),
),
recorder.states,
)
assertEquals(
listOf(CounterEvent.Incremented(count = 1)),
recorder.events,
)
}If you need custom recording behavior, implement your own StoreObserver and attach it with attachObserver().
If your action {} or enter {} logic launches additional coroutines with launch {}, or if you need virtual time control, use test dispatcher and scheduler control separately.
The Tart logo (doc/logo.svg) is licensed under CC0 1.0 Universal. You may use it freely for any purpose without attribution. See doc/LOGO_LICENSE for details.
[!IMPORTANT] We are considering moving this repository to a different organization. As part of that move, the library group name may change.
Tart is a state management framework for Kotlin Multiplatform.
Key benefits:
The architecture is inspired by Flux and is as follows:
Tart works especially well when a feature has multiple explicit UI or business states and the transition rules between them are important.
By combining Kotlin sealed class/sealed interface with Tart's state machine DSL, you can keep each state's enter{}, action{}, exit{}, and error{} behavior close together and make the transition rules easy to follow.
Tart currently focuses on the core pieces of state management: explicit state transitions, coroutine-based asynchronous work, state persistence, and middleware-driven extensions such as logging and inter-store messaging. It keeps surrounding helper layers intentionally small, so dependencies and feature composition can stay in ordinary Kotlin, while the core Store logic remains portable across platforms.
implementation("io.yumemi.tart:tart-core:<latest-release>")Let’s take a simple counter app as an example. First, define the State and Action classes.
data class CounterState(val count: Int) : State
sealed interface CounterAction : Action {
data object Increment : CounterAction
data object Decrement : CounterAction
}Create a Store using the Store{} DSL and an initial State.
val store: Store<CounterState, CounterAction, Nothing> = Store(CounterState(count = 0)) {}
// or, use the initialState() specification
val store: Store<CounterState, CounterAction, Nothing> = Store {
initialState(CounterState(count = 0))
}Define how Actions change State using the state{} and action{} blocks.
Specify the resulting State with nextState().
If you want to compute and return the next state inside a block, use nextStateBy { ... }.
If neither nextState() nor nextStateBy() is specified, the current state remains unchanged.
val store: Store<CounterState, CounterAction, Nothing> = Store(CounterState(count = 0)) {
state<CounterState> {
action<CounterAction.Increment> {
nextState(state.copy(count = state.count + 1))
}
action<CounterAction.Decrement> {
if (0 < state.count) {
nextState(state.copy(count = state.count - 1))
} else {
// do not change State
}
}
}
}For conditional or complex updates, nextStateBy {} can make the computation easier to read.
nextStateBy {
// ...
val newCount = ...
state.copy(count = newCount)
}The Store setup is complete. Keep the store instance in a ViewModel (or similar).
Dispatch an Action from the UI using the Store's dispatch() method.
// example in Compose
Button(
onClick = { store.dispatch(CounterAction.Increment) },
) {
Text(text = "increment")
}The new State is exposed via the Store's .state (StateFlow), so render it in the UI.
Define your Event class and set it as the third type parameter of Store.
sealed interface CounterEvent : Event {
data class ShowToast(val message: String) : CounterEvent
data object NavigateToNextScreen : CounterEvent
}val store: Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {
// ...
}In an action{} block, specify an Event with event().
action<CounterAction.Decrement> {
if (0 < state.count) {
nextState(state.copy(count = state.count - 1))
} else {
event(CounterEvent.ShowToast("Can not Decrement.")) // raise event
}
}Collect the Store's .event (Flow) in the UI and handle it.
Have repositories and UseCase classes available in your store creation scope and use them inside action{} blocks.
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {
state<CounterState> {
action<CounterAction.Load> {
val count = counterRepository.get() // load
nextState(state.copy(count = count))
}
action<CounterAction.Increment> {
val count = state.count + 1
counterRepository.set(count) // save
nextState(state.copy(count = count))
}
// ...
}
}
// or, define a Store class using delegation
class CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> by Store(
initialState = CounterState(count = 0),
setup = {
state<CounterState> {
// ...
}
},
)Processing other than changing the State may be defined as functions, as they tend to become complex and lengthy.
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {
// define as a function
suspend fun loadCount(): Int {
return counterRepository.get()
}
state<CounterState> {
action<CounterAction.Load> {
nextState(state.copy(count = loadCount())) // call the function
}
// ...You may also define them as extension functions of State or Action.
In the previous examples, the State was single. If you need multiple States (for example, a UI during data loading), define them explicitly.
sealed interface CounterState : State {
data object Loading : CounterState
data class Main(val count: Int) : CounterState
}fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState.Loading> { // for Loading state
action<CounterAction.Load> {
val count = counterRepository.get()
nextState(CounterState.Main(count = count)) // transition to Main state
}
}
state<CounterState.Main> { // for Main state
action<CounterAction.Increment> {
// ...In this example, the CounterAction.Load action needs to be issued from the UI when the application starts.
If you want to run logic when a State starts, use the enter{} block (similarly, you can use the exit{} block if necessary).
fun CounterStore(
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState.Loading> {
enter {
val count = counterRepository.get()
nextState(CounterState.Main(count = count)) // transition to Main state
}
}
state<CounterState.Main> {
action<CounterAction.Increment> {
// ...The state diagram is as follows:
This framework's architecture can be easily visualized using state diagrams. It would be a good idea to document it and share it with your development team.
You can also target a parent sealed type in state<...>{} or action<...>{} when the same handler should apply across multiple variants.
val store: Store<CounterState, CounterAction, CounterEvent> = Store(CounterState.Loading) {
state<CounterState> {
enter {
// runs for all CounterState variants
}
}
state<CounterState.Main> {
action<CounterAction> {
when (action) {
CounterAction.Increment -> {
// ...
}
CounterAction.Decrement -> {
// ...
}
CounterAction.Load -> Unit
}
}
}
}Handler selection is first-match. If both broad and specific handlers can match, the one registered earlier is used. In practice, place broader handlers after more specific ones.
state<CounterState.Loading> {
action<CounterAction.Load> {
// specific handler
}
}
state<CounterState> {
action<CounterAction> {
// broader fallback handler
}
}If you prepare a State for error display and handle the error in the enter{} block, it will be as follows:
sealed interface CounterState : State {
// ...
data class Error(val error: Exception) : CounterState
}val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
state<CounterState.Loading> {
enter {
try {
val count = counterRepository.get()
nextState(CounterState.Main(count = count))
} catch (e: Exception) {
nextState(CounterState.Error(error = e))
}
}
}
}This works, but you can also handle errors with the error{} block.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
state<CounterState.Loading> {
enter {
// no error handling code
val count = counterRepository.get()
nextState(CounterState.Main(count = count))
}
// more specific exceptions should be placed first
error<IllegalStateException> {
// ...
nextState(CounterState.Error(error = error))
}
// more general exception handlers should come last
error<Exception> {
// ...
nextState(CounterState.Error(error = error))
}
}
}Errors can be caught not only in the enter{} block but also in the action{} and exit{} blocks.
In other words, your business logic errors can be handled in the error{} block.
On the other hand, uncaught errors in the entire Store (such as system errors) can be handled with the exceptionHandler() specification:
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
exceptionHandler(...)
}You can also create an ExceptionHandler instance with the ExceptionHandler() factory function.
You can use launch{} in both enter{} and action{} blocks to run asynchronous work and update State (or emit Events).
This is useful for integrating long-running tasks such as flow collection, network calls, and background processing:
state<MyState.Active> {
enter {
// launch a coroutine that lives as long as this state is active
launch {
// collect from an external data source
dataRepository.observeData().collect { newData ->
// update state with the new data in a transaction
transaction {
nextState(state.copy(data = newData))
}
}
}
}
}You can also start asynchronous work from an action:
state<MyState.Active> {
action<MyAction.Refresh> {
launch {
// state updates in launch must be done in transaction{} block
transaction {
nextState(state.copy(isRefreshing = true))
}
dataRepository.refresh()
transaction {
nextState(state.copy(isRefreshing = false))
}
}
}
}This pattern lets your Store react to external data changes automatically, such as database updates, user preference changes, or network events.
Coroutines started by launch{} are automatically cancelled when the State changes to a different State, making it easy to manage resources and subscriptions.
In action{}, launch{} is tied to the State active at action start.
If you want lightweight coordination and explicit cancellation for coroutines launched from an action handler, set the control directly on launch(...):
val store = Store(MyState.Active()) {
val searchLane = object {}
val submitLane = object {}
state<MyState.Active> {
action<MyAction.QueryChanged> {
nextState(state.copy(query = action.query, isLoading = true))
launch(control = LaunchControl.Replace(searchLane)) {
delay(300)
val result = repository.search(action.query)
transaction {
nextState(
state.copy(
result = result,
isLoading = false,
),
)
}
}
}
action<MyAction.ClearQuery> {
cancelLaunch(searchLane)
nextState(
state.copy(
query = "",
result = emptyList(),
isLoading = false,
),
)
}
action<MyAction.Submit> {
launch(control = LaunchControl.DropNew(submitLane)) {
submit()
}
}
}
}LaunchControl.Replace(key) cancels the previous tracked launch in the same lane before starting the next one.
LaunchControl.DropNew(key) ignores new launches while tracked work in the same lane is still active.
LaunchControl.Concurrent keeps the default behavior and runs launches independently.
cancelLaunch(key) only affects coroutines started from action { launch { ... } } in the current active state's runtime that use tracked controls such as LaunchControl.Replace(...) and LaunchControl.DropNew(...). It does not cancel LaunchControl.Concurrent launches or enter { launch { ... } }.
The Store operates using Coroutines, and the default CoroutineContext is EmptyCoroutineContext + Dispatchers.Default.
Specify it to align the Store's Coroutines lifecycle with another context or to change the execution thread.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(...)
}If you don’t use an auto-disposed scope like ViewModel's viewModelScope or Compose's rememberCoroutineScope(), call Store's .dispose() method explicitly when the Store is no longer needed.
Then, processing of all Coroutines will stop.
You can specify the execution thread (CoroutineDispatchers) in enter{}, exit{}, action{}, error{}, and launch{} blocks, allowing you to locally control which thread each specific operation runs on.
enter(Dispatchers.Default) {
// work on CPU thread..
launch(Dispatchers.IO) {
// This code runs on IO thread
val updates = dataRepository.observeUpdates()
updates.collect { newData ->
// ...
}
}
}Alternatively, you can use Coroutines' withContext().
enter {
withContext(Dispatchers.Default) {
// work on CPU thread..
withContext(Dispatchers.IO) {
// This code runs on IO thread
launch {
val updates = dataRepository.observeUpdates()
updates.collect { newData ->
// ...
}
}
}
}
}You can prepare a StateSaver to automatically handle State persistence:
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
stateSaver(...)
}You can also create a StateSaver instance with the StateSaver() factory function.
By default, Tart clears already queued actions when the store exits the current state and enters a different state variant.
To keep queued actions across state exits, set pendingActionPolicy(PendingActionPolicy.KEEP).
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
pendingActionPolicy(PendingActionPolicy.KEEP)
}Regardless of the configured PendingActionPolicy, you can still discard already queued actions at a specific point by calling clearPendingActions() inside enter{}, action{}, exit{}, error{}, or inside transaction{} from a launched coroutine.
The body of Store{} is ordinary Kotlin code, so you can use control flow such as if and when when specifying Store configuration.
fun CounterStore(
logExceptions: Boolean,
): Store<CounterState, CounterAction, Nothing> = Store {
initialState(CounterState(count = 0))
if (logExceptions) {
exceptionHandler(ExceptionHandler.Log)
}
}On platforms where Store's .state (StateFlow) and .event (Flow) cannot be consumed directly (e.g., iOS), use .collectState() and .collectEvent().
If the State or Event changes, you will be notified through these callbacks.
You can use Store's .state (StateFlow), .event (Flow), and .dispatch() directly, but we provide a mechanism for Compose.
implementation("io.yumemi.tart:tart-compose:<latest-release>")Create an instance of the ViewStore from a Store using the rememberViewStore() function.
For example, if you have a Store in ViewModels, it would look like this:
fun CounterStore(
coroutineContext: CoroutineContext,
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(coroutineContext)
}@HiltViewModel
class CounterViewModel @Inject constructor(
counterRepository: CounterRepository,
) : ViewModel() {
val store = CounterStore(
coroutineContext = viewModelScope.coroutineContext,
counterRepository = counterRepository,
)
}@Composable
fun CounterScreen(
// create an instance of ViewStore
viewStore: ViewStore<CounterState, CounterAction, CounterEvent> = rememberViewStore {
hiltViewModel<CounterViewModel>().store
},
) {
// ...
// pass the ViewStore instance to lower components if necessary
YourComposable(
viewStore = viewStore,
)
}Alternatively, you can use the Store{} DSL directly in the ViewModel as follows, but note that in this case you need tests for CounterViewModel, and sharing a Store across multiple platforms becomes harder.
@HiltViewModel
class MainViewModel @Inject constructor(
counterRepository: CounterRepository,
) : ViewModel() {
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(viewModelScope.coroutineContext)
}
}You can create a ViewStore instance without using ViewModel as shown below:
fun CounterStore(
coroutineContext: CoroutineContext,
stateSaver: StateSaver<CounterState>,
counterRepository: CounterRepository,
): Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(coroutineContext)
stateSaver(stateSaver)
}@Composable
fun CounterScreen(
viewStore: ViewStore<CounterState, CounterAction, CounterEvent> = rememberViewStore {
CounterStore(
coroutineContext = rememberCoroutineScope().coroutineContext, // or, specify the autoDispose option in rememberViewStore {}
stateSaver = rememberStateSaver(), // state persistence during screen rotation, etc.
counterRepository = CounterRepositoryImpl(),
)
},
) {
// ...
}If you inject instances such as Repository using a DI library, it is useful to create a class like the following.
class CounterStoreContainer(
private val counterRepository: CounterRepository,
) {
fun build(coroutineContext: CoroutineContext, stateSaver: StateSaver<CounterState>): Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(coroutineContext)
stateSaver(stateSaver)
}
}If there’s a single State, just use ViewStore's .state property.
Text(
text = viewStore.state.count.toString(),
)If there are multiple States, use the .render() method for the target State.
viewStore.render<CounterState.Main> {
Text(
text = state.count.toString(),
)
}When drawing the UI, if it does not match the target State, the .render() will not be executed.
Therefore, you can define components for each State side by side.
viewStore.render<CounterState.Loading> {
Text(
text = "loading..",
)
}
viewStore.render<CounterState.Main> {
Text(
text = state.count.toString(),
)
}If you use lower components in the render() block, pass its instance.
viewStore.render<CounterState.Main> {
YourComposable(
viewStore = this, // ViewStore instance for CounterState.Main
)
}@Composable
fun YourComposable(
// Main state is confirmed
viewStore: ViewStore<CounterState.Main, CounterAction, CounterEvent>,
) {
Text(
text = viewStore.state.count.toString()
)
}Use ViewStore's .dispatch() with the target Action.
Button(
onClick = { viewStore.dispatch(CounterAction.Increment) },
) {
Text(
text = "increment"
)
}Use ViewStore's .handle() with the target Event.
viewStore.handle<CounterEvent.ShowToast> { event ->
// do something..
}In the above example, you can also subscribe to the parent Event type.
viewStore.handle<CounterEvent> { event ->
when (event) {
is CounterEvent.ShowToast -> // do something..
is CounterEvent.GoBack -> // do something..
// ...Create an instance of ViewStore directly with the target State.
@Preview
@Composable
fun LoadingPreview() {
MyApplicationTheme {
CounterScreen(
viewStore = ViewStore(
state = CounterState.Loading,
),
)
}
}Therefore, if you prepare only the State, it is possible to develop the UI.
You can create extensions that work with the Store.
To do this, create a class that implements the Middleware interface and override the necessary methods.
class YourMiddleware<S : State, A : Action, E : Event> : Middleware<S, A, E> {
override suspend fun afterStateChange(state: S, prevState: S) {
// do something..
}
}Apply the created Middleware as follows:
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
// add Middleware instance
middleware(YourMiddleware())
// or, implement Middleware directly here
middleware(
object : Middleware<CounterState, CounterAction, CounterEvent> {
override suspend fun afterStateChange(state: CounterState, prevState: CounterState) {
// do something..
}
},
)
// add multiple Middlewares
middleware(..., ...)
}Note that State is read-only in Middleware.
You can also create a Middleware instance with the Middleware() factory function.
Middleware methods are suspending functions. The Store waits for middleware processing to complete before proceeding.
When multiple middleware instances are registered, Tart invokes them concurrently by default.
If middleware must run one by one in registration order, set middlewareExecutionPolicy(MiddlewareExecutionPolicy.IN_REGISTRATION_ORDER).
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
middlewareExecutionPolicy(MiddlewareExecutionPolicy.IN_REGISTRATION_ORDER)
}Because a long-running method can block the Store, run heavy work in a separate CoroutineScope.
In the next section, we introduce built-in Middleware.
The source code is the :tart-logging and :tart-message modules in this repository, so you can use it as a reference for your Middleware implementation.
Middleware for logging Store operations.
implementation("io.yumemi.tart:tart-logging:<latest-release>")Apply the simpleLogging() middleware factory function to your Store to log all actions, events, state changes, and errors.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
middleware(simpleLogging())
}You can create custom logging middleware by extending the LoggingMiddleware class if you need more control over the logging behavior.
middleware(
object : LoggingMiddleware<CounterState, CounterAction, CounterEvent>(
logger = YourLogger() // specify your logger
) {
// override methods
override suspend fun beforeStateEnter(state: CounterState) {
log(...)
}
},
)Middleware for sending messages between Stores.
implementation("io.yumemi.tart:tart-message:<latest-release>")First, prepare classes for messages.
sealed interface MainMessage : Message {
data object LoggedOut : MainMessage
data class CommentLiked(val commentId: Int) : MainMessage
}Apply the receiveMessages() middleware factory function to the Store that receives messages.
val myPageStore: Store<MyPageState, MyPageAction, MyPageEvent> = Store {
// ...
middleware(
receiveMessages { message ->
when (message) {
MainMessage.LoggedOut -> dispatch(MyPageAction.doLogoutProcess)
// ...
}
}
)
}Define the message() specification at any point in the Store that sends messages.
val mainStore: Store<MainState, MainAction, MainEvent> = Store {
// ...
state<MainState.LoggedIn> { // leave the logged-in state
exit {
message(MainMessage.LoggedOut)
}
}
}Store{} DSL accepts an overrides block that is applied after the main setup block.
Use it when you want to override Store configuration.
fun CounterStore(
overrides: Overrides<CounterState, CounterAction, Nothing> = {},
): Store<CounterState, CounterAction, Nothing> = Store(
initialState = CounterState(count = 0),
overrides = overrides,
) {
middleware(AppLoggingMiddleware())
state<CounterState> {
// ...
}
}
val store = CounterStore()
val testStore = CounterStore(
overrides = {
clearMiddlewares()
exceptionHandler(ExceptionHandler.Log)
},
)Inside overrides block, you can use these APIs:
coroutineContext(...)stateSaver(...)exceptionHandler(...)middleware(...)clearMiddlewares()replaceMiddlewares(...)pendingActionPolicy(...)middlewareExecutionPolicy(...)Typical uses are:
In larger projects, it can be useful to wrap Store{} DSL in a project-specific AppStore{} that applies app-wide defaults in one place.
This lets you centralize shared Store configuration.
fun <S : State, A : Action, E : Event> AppStore(
initialState: S,
overrides: Overrides<S, A, E> = {},
setup: Setup<S, A, E>,
): Store<S, A, E> = Store(
initialState = initialState,
overrides = overrides,
) {
// shared Store configuration
middleware(AppLoggingMiddleware())
exceptionHandler(AppExceptionHandler)
setup()
}A feature Store can then focus on its own state transitions and actions:
fun CounterStore(
counterRepository: CounterRepository,
overrides: Overrides<CounterState, CounterAction, CounterEvent> = {},
): Store<CounterState, CounterAction, CounterEvent> = AppStore( // use AppStore{}
initialState = CounterState(count = 0),
overrides = overrides,
) {
state<CounterState> {
// ...
}
}
val store = CounterStore(counterRepository = counterRepository)
val testStore = CounterStore(
counterRepository = counterRepository,
overrides = {
clearMiddlewares()
// ...
},
)Add :tart-test to your test source set to use Tart's test helpers such as createRecorder() and dispatchAndWait().
commonTestImplementation("io.yumemi.tart:tart-test:<latest-release>")For most Store tests, use createRecorder() to create and attach the default StoreRecorder, then assert recorded state and event history.
@Test
fun counterStore_recordsStatesAndEvents() = runTest {
// Given
val store = CounterStore(...)
val recorder = store.createRecorder()
// When
store.dispatchAndWait(CounterAction.Increment) // wait until the dispatched action completes
// Then
assertEquals(
listOf(
CounterState(count = 0),
CounterState(count = 1),
),
recorder.states,
)
assertEquals(
listOf(CounterEvent.Incremented(count = 1)),
recorder.events,
)
}If you need custom recording behavior, implement your own StoreObserver and attach it with attachObserver().
If your action {} or enter {} logic launches additional coroutines with launch {}, or if you need virtual time control, use test dispatcher and scheduler control separately.