Tart is a state management framework for Kotlin Multiplatform.
- Data flow is one-way, making it easy to understand.
- Since the state remains unchanged during processing, there is no need to worry about side effects.
- Code becomes declarative.
- Writing test code is straightforward and easy.
- Works on multiple platforms (currently on Android and iOS).
- Enables code sharing and consistent logic implementation across platforms.
The architecture is inspired by Flux and is as follows:
implementation("io.yumemi.tart:tart-core:<latest-release>")
Take a simple counter application as an example.
First, prepare classes for State, Action, and Event.
data class CounterState(val count: Int) : State
sealed interface CounterAction : Action {
data object Increment : CounterAction
data object Decrement : CounterAction
}
sealed interface CounterEvent : Event {} // currently empty
Create a Store instance using the Store{}
DSL with an initial State.
val store: Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {}
// or, use initialState specification
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
initialState(CounterState(count = 0))
}
Define how the State is changed by Action by using the state{}
and action{}
blocks.
Specify the resulting State using the nextState()
specification.
If no nextState()
is specified, the current state remains unchanged.
For complex state updates with conditional logic, you can use nextStateBy{}
with a block that computes and returns the new state.
val store: Store<CounterState, CounterAction, CounterEvent> = 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
}
}
}
}
The Store preparation is now complete. Keep the store instance in the ViewModel etc.
Issue 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 will be reflected in the Store's .state
(StateFlow) property, so draw it to the UI.
Prepare classes for Event.
sealed interface CounterEvent : Event {
data class ShowToast(val message: String) : CounterEvent
data object NavigateToNextScreen : CounterEvent
}
In a action{}
block, specify that Event using the event()
specification.
action<CounterAction.Decrement> {
if (0 < state.count) {
nextState(state.copy(count = state.count - 1))
} else {
event(CounterEvent.ShowToast("Can not Decrement.")) // issue event
}
}
Subscribe to the Store's .event
(Flow) property on the UI, and process it.
Keep Repository, UseCase, etc. accessible from your store creation scope and use them in the action{}
block.
class CounterStoreBuilder( // instantiate with DI library etc.
private val counterRepository: CounterRepository,
) {
fun build(): 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, create a Store simply like this with a function:
fun createCounterStore(
counterRepository: CounterRepository
): Store<CounterState, CounterAction, CounterEvent> = Store(CounterState(count = 0)) {
// ...
}
TIPS: Define functions as needed
Processing other than changing the State may be defined as functions, as they tend to become complex and lengthy.
class CounterStoreBuilder(
private val counterRepository: CounterRepository,
) {
fun build(): 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. However, if there are multiple States, for example a UI during data loading, prepare multiple States.
sealed interface CounterState : State {
data object Loading: CounterState
data class Main(val count: Int): CounterState
}
class CounterStoreBuilder(
private val counterRepository: CounterRepository,
) {
fun build(): 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.
Otherwise, if you want to do something at the start of the State, use the enter{}
block (similarly, you can use the exit{}
block if necessary).
class CounterStoreBuilder(
private val counterRepository: CounterRepository,
) {
fun build(): 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.
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 (t: Throwable) {
nextState(CounterState.Error(error = t))
}
}
}
}
This is fine, but you can also handle errors using 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
// you can also catch just 'Exception' and branch based on the type of the error property inside the block
error<Exception> { // catches any remaining exceptions
// ...
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 use the launch{}
specification in the enter{}
block to collect flows and dispatch Actions.
This is useful for connecting external data streams to your Store:
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))
}
}
}
}
}
This pattern allows your Store to automatically react to external data changes, such as database updates, user preferences changes, or network events. The flow collection will be automatically cancelled when the State changes to a different State, making it easy to manage resources and subscriptions.
The Store operates using Coroutines, and the default CoroutineContext is EmptyCoroutineContext + Dispatchers.Default
.
Specify it when you want to match the Store's Coroutines lifecycle with another context or change the thread on which it operates.
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
coroutineContext(...)
}
If you do not specify a context that is automatically disposed 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(...)
}
Coroutines like Store's .state
(StateFlow) property and .event
(Flow) property cannot be used on iOS, so use the .collectState()
and .collectEvent()
methods.
If the State or Event changes, you will be notified through these callbacks.
contents
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 instance using the rememberViewStore()
function.
For example, if you have a Store in ViewModels, it would look like this:
class CounterStoreContainer(
private val counterRepository: CounterRepository,
) {
fun build(): Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
}
}
@HiltViewModel
class CounterViewModel @Inject constructor(
counterStoreBuilder: CounterStoreBuilder,
) : ViewModel() {
val store = counterStoreBuilder.build()
override fun onCleared() {
store.dispose()
}
}
@AndroidEntryPoint
class CounterActivity : ComponentActivity() {
private val counterViewModel: CounterViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// create an instance of ViewStore
val viewStore = rememberViewStore(counterViewModel.store)
MyApplicationTheme {
Surface(
modifier = Modifier.fillMaxSize(),
) {
// pass the ViewStore instance to lower components if necessary
YourComposable(
viewStore = viewStore,
)
// ...
You can create a ViewStore
instance without using ViewModel as shown below:
class CounterStoreBuilder(
private val counterRepository: CounterRepository,
) {
fun build(stateSaver: StateSaver<CounterState>): Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
stateSaver(stateSaver)
}
}
@AndroidEntryPoint
class CounterActivity : ComponentActivity() {
@Inject
lateinit var counterStoreBuilder: CounterStoreBuilder
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val viewStore = rememberViewStore(
counterStoreBuilder.build(
stateSaver = rememberStateSaver(), // state persistence during screen rotation, etc.
)
)
// ...
If the State is single, just use ViewStore's .state
property.
Text(
text = viewStore.state.count.toString(),
)
If there are multiple States, use .render()
method with 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 {
YourComposable(
viewStore = ViewStore(
state = CounterState.Loading,
),
)
}
}
Therefore, if you prepare only the State, it is possible to develop the UI.
contents
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
middlewares(..., ...)
}
Note that State is read-only in Middleware.
Each Middleware method is a suspending function, so it can be run synchronously (not asynchronously) with the Store. However, since it will interrupt the Store process, you should prepare a new CoroutineScope for long processes.
In the next section, we will introduce pre-prepared 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 that outputs logs for debugging and analysis.
implementation("io.yumemi.tart:tart-logging:<latest-release>")
val store: Store<CounterState, CounterAction, CounterEvent> = Store {
// ...
middleware(LoggingMiddleware())
}
The implementation of the LoggingMiddleware
is here, change the arguments or override
methods as necessary.
If you want to change the logger, prepare a class that implements the Logger
interface.
middleware(
object : LoggingMiddleware<CounterState, CounterAction, CounterEvent>(
logger = YourLogger() // change logger
) {
// override other methods
override suspend fun beforeStateEnter(state: CounterState) {
// ...
}
},
)
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 MessageMiddleware
to the Store that receives messages.
val myPageStore: Store<MyPageState, MyPageAction, MyPageEvent> = Store {
// ...
middleware(
MessageMiddleware { 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)
}
}
}
Tart's architecture makes writing unit tests for your Store straightforward.
For test examples, see the commonTest directory in the :tart-core
module.
I used Flux and UI layer as a reference for the design, and Macaron for the implementation.