Tart is a state management framework for Kotlin Multiplatform.
- Data flow is one-way, making it easy to understand.
- Since the state during processing is unchanged, there is no need to be aware of side effects.
- Code becomes declarative.
- Works on multiple platforms (currently on Android and iOS).
The architecture is inspired by Flux and is as follows:
The processing on the Store is expressed by the following function:
(State, Action) -> State
In this framework, based on the above function, we only need to be concerned with the relationship between State and Action.
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 class Set(val count: Int) : CounterAction
data object Increment : CounterAction
data object Decrement : CounterAction
}
sealed interface CounterEvent : Event {} // currently empty
Create a Store class from Store.Base
with an initial State.
class CounterStore : Store.Base<CounterState, CounterAction, CounterEvent>(
initialState = CounterState(count = 0),
)
Overrides the onDispatch()
and define how the State is changed by Action.
This is a (State, Action) -> State
function.
class CounterStore : Store.Base<CounterState, CounterAction, CounterEvent>(
initialState = CounterState(count = 0),
) {
override suspend fun onDispatch(state: CounterState, action: CounterAction): CounterState = when (action) {
is CounterAction.Set -> {
state.copy(count = action.count)
}
is CounterAction.Increment -> {
state.copy(count = state.count + 1)
}
is CounterAction.Decrement -> {
if (0 < state.count) {
state.copy(count = state.count - 1)
} else {
state // do not change State
}
}
}
}
The Store preparation is now complete.
Instantiate the CounterStore
class and keep it in the ViewModel etc.
Issue an Action from the UI using the Store's dispatch()
.
// example in Compose
Button(
onClick = { counterStore.dispatch(CounterAction.Increment) },
) {
Text(text = "increment")
}
The new State will be reflected in the Store's .state
(StateFlow), 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 the dispatch()
method body, issue an Event using the emit()
.
is CounterAction.Decrement -> {
if (0 < state.count) {
state.copy(count = state.count - 1)
} else {
emit(CounterEvent.ShowToast("Can not Decrement.")) // issue event
state
}
}
Subscribe to the Store's .event
(Flow) on the UI, and process it.
Keep Repository, UseCase, etc. in the instance field of Store and use it from dispatch()
method body.
class CounterStore(
private val counterRepository: CounterRepository, // inject to Store
) : Store.Base<CounterState, CounterAction, CounterEvent>(
initialState = CounterState(count = 0),
) {
override suspend fun onDispatch(state: CounterState, action: CounterAction): CounterState = when (action) {
CounterAction.Load -> {
val count = counterRepository.get() // load
state.copy(count = count)
}
is CounterAction.Increment -> {
val count = state.count + 1
state.copy(count = count).apply {
counterRepository.set(count) // save
}
}
// ...
Tip
Processing other than changing the State may be defined using extension functions for State or Action.
override suspend fun onDispatch(state: CounterState, action: CounterAction): CounterState = when (action) {
CounterAction.Load -> {
val count = action.loadCount() // call extension function
state.copy(count = count)
}
// ...
}
// describe what to do for this Action
private suspend fun CounterAction.Load.loadCount(): Int {
return counterRepository.get()
}
In any case, the onDispatch()
is a simple method that simply returns a new State from the current State and Action, so you can design the code as you like.
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 CounterStore(
private val counterRepository: CounterRepository,
) : Store.Base<CounterState, CounterAction, CounterEvent>(
initialState = CounterState.Loading, // start from loading
) {
override suspend fun onDispatch(state: CounterState, action: CounterAction): CounterState = when (state) {
CounterState.Loading -> when (action) {
CounterAction.Load -> {
val count = counterRepository.get()
CounterState.Main(count = count) // transition to Main state
}
else -> state
}
is CounterState.Main -> when (action) {
is 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, override the onEnter()
(similarly, you can override the onExit()
if necessary).
override suspend fun onEnter(state: CounterState): CounterState = when (state) {
CounterState.Loading -> {
val count = counterRepository.get()
CounterState.Main(count = count) // transition to Main state
}
else -> state
}
override suspend fun onDispatch(state: CounterState, action: CounterAction): CounterState = when (state) {
is CounterState.Main -> when (action) {
is CounterAction.Increment -> {
// ...
The state diagram is as follows:
This framework works well with 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 onEnterDidpatch()
, it will be as follows:
sealed interface CounterState : State {
// ...
data class Error(val error: Throwable) : CounterState
}
override suspend fun onEnter(state: CounterState): CounterState = when (state) {
CounterState.Loading -> {
try {
val count = counterRepository.get()
CounterState.Main(count = count)
} catch (t: Throwable) {
CounterState.Error(error = t)
}
}
else -> state
This is fine, but you can also handle errors by overriding the onError()
.
override suspend fun onEnter(state: CounterState): CounterState = when (state) {
CounterState.Loading -> {
// no error handling code
val count = counterRepository.get()
CounterState.Main(count = count)
}
else -> state
}
override suspend fun onError(state: CounterState, error: Throwable): CounterState {
// you can also branch using state and error inputs if necessary
return CounterState.Error(error = error)
}
Errors can be caught not only in the onEnter()
but also in the onDispatch()
and onExit()
.
Specify the first State.
class CounterStore : Store.Base<CounterState, CounterAction, CounterEvent>(
initialState = CounterState.Loading,
)
Also, specify it when restoring the State saved to ViewModel's SavedStateHandle etc. On the other hand, to save the State, it is convenient to obtain the latest State using the collectState().
You can pass any CoroutieneContext
.
If omitted, the default context will be used.
more
If you keep the Store in Android's ViewModel, it will be viewModelScope.coroutineContext
.
class CounterStore(
coroutineContext: CoroutineContext, // pass to Store.Base
) : Store.Base<CounterState, CounterAction, CounterEvent>(
initialState = CounterState.Loading,
coroutineContext = coroutineContext,
)
// ...
class CounterViewModel : ViewModel() {
val store = CounterStore(viewModelScope.coroutineContext)
}
If you are not using ViewModel, lifecycleScope.coroutineContext
can be used on Android.
In these cases, the Store's Coroutines will be disposed of according to the those lifecycle.
In this way, when using viewModelScope.coroutineContext
or lifecycleScope.coroutineContext
, call the Store constructor on the ViewModel or Activity to pass CoroutieneContext
.
And, if you need Repository, UseCase, etc., it is necessary to inject them into ViewModel or Activity.
class CounterViewModel(
counterRepository: CounterRepository, // inject to ViewModel
) : ViewModel() {
val store = CounterStore(
counterRepository = counterRepository, // pass to Store
coroutineContext = viewModelScope.coroutineContext,
)
}
In such cases, it is better to prepare a factory class as follows:
// provide with DI libraries
class CounterStoreFactory(
private val counterRepository: CounterRepository,
) {
fun create(coroutineContext: CoroutineContext): CounterStore {
return CounterStore(
counterRepository = counterRepository,
coroutineContext = coroutineContext,
)
}
}
// ...
class CounterViewModel(
counterStoreFactory: CounterStoreFactory, // inject to ViewModel
) : ViewModel() {
val store = counterStoreFactory.create(viewModelScope.coroutineContext)
}
Even if you omit the CoroutieneContext
specification, it is a good practice to prepare a factory class for creating a Store.
Uncaught errors can be received with this callback. For logging, do as follows:
class CounterStore(
logger: YourLogger,
) : Store.Base<CounterState, CounterAction, CounterEvent>(
initialState = CounterState.Loading,
onError = { logger.log(it) },
)
Coroutines like Store's .state
(StateFlow) and .event
(Flow) cannot be used on iOS, so use the .collectState()
and .collectEvent()
.
If the State or Event changes, you will be notified through these callbacks.
If you are not using an automatically disposed scope like Android's ViewModelScope
or LificycleScope
, call Store's .dispose()
explicitly when Store is no longer needed.
Then, processing of all Coroutines will stop.
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()
.
For example, if you have a Store in ViewModels, it would look like this:
class CounterActivity : ComponentActivity() {
private val counterViewModel: CounterViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// create an instance of ViewStore at the top level of Compose
val viewStore = rememberViewStore(counterViewModel.store)
MyApplicationTheme {
Surface(
modifier = Modifier.fillMaxSize(),
) {
// pass the ViewStore instance to lower components
YourComposableComponent(
viewStore = viewStore,
)
// ...
Even if you create an instance of the ViewStore
as shown below, you can restore State when the Activity configuration changes without using ViewModel.
However, note that States must implement Parcelable
or Serializable
because they are used internally for rememberSaveable.
class CounterActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// create an instance of ViewStore with support for State restore
val viewStore = rememberViewStore { savedState: CounterState? ->
CounterStore(
initialState = savedState ?: CounterState.Loading,
coroutineContext = coroutineContext,
)
}
// ...
If the State is single, just use ViewStore's .state
.
Text(
text = viewStore.state.count.toString(),
)
If there are multiple States, use .render()
method with 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> {
YourComposableComponent(
viewStore = this, // ViewStore instance for CounterState.Main
)
}
// ...
@Composable
fun YourComposableComponent(
// Main state is confirmed
viewStore: ViewStore<CounterState.Main, CounterAction, CounterEvent>,
) {
Text(
text = viewStore.state.count.toString()
)
}
Use ViewStore's .dispatch()
with target Action.
Button(
onClick = { viewStore.dispatch(CounterAction.Increment) },
) {
Text(
text = "increment"
)
}
Use ViewStore's .handle()
with 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 using the mock()
with target State.
You can statically create a ViewStore instance without a Store instance.
@Preview
@Composable
fun LoadingPreview() {
MyApplicationTheme {
YourComposableComponent(
viewStore = ViewStore.mock(
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:
class MainStore(
// ...
) : Store.Base<CounterState, CounterAction, CounterEvent>(
// ...
) {
override val middlewares: List<Middleware<CounterState, CounterAction, CounterEvent>> = listOf(
// add Middleware instance to List
YourMiddleware(),
// or, implement Middleware directly here
object : Middleware<CounterState, CounterAction, CounterEvent> {
override suspend fun afterStateChange(state: CounterState, prevState: CounterState) {
// do something..
}
},
)
// ...
You can also list a Middleware instance created with DI Libraries.
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.
Note that State is read-only in Middleware.
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>")
override val middlewares: List<Middleware<CounterState, CounterAction, CounterEvent>> = listOf(
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.
override val middlewares: List<Middleware<CounterState, CounterAction, CounterEvent>> = listOf(
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.
override val middlewares: List<Middleware<MyPageState, MyPageAction, MyPageEvent>> = listOf(
object : MessageMiddleware<MyPageState, MyPageAction, MyPageEvent>() {
override suspend fun receive(message: Message, dispatch: (action: MyPageAction) -> Unit) {
when (message) {
is MainMessage.LoggedOut -> dispatch(MyPageAction.doLogoutProcess)
// ...
}
}
},
)
Call the send()
at any point in the Store that sends messages.
override suspend fun onExit(state: MainState) = when (state) {
is MainState.LoggedIn -> { // leave the logged-in state
send(MainMessage.LoggedOut)
}
// ...
I used Flux and UI layer as a reference for the design, and Macaron for the implementation.