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
}
}
// ...
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.
Tips: define extension functions for each State
Normally, code for all States is written in the body of the onDispatch()
method.
override suspend fun onDispatch(state: MainState, action: MainAction): MainState = when (state) {
is MainState.StateA -> when (action) {
is MainAction.ActionA -> {
// do something..
}
is MainAction.ActionB -> {
// do something..
}
// ...
else -> state
}
is MainState.StateB -> when (action) {
// ...
}
// ...
This is fine if the code is simple, but if the code becomes long, define an extension function for each State. Code for each State becomes easier to understand.
override suspend fun onDispatch(state: MainState, action: MainAction): MainState = when (state) {
is MainState.StateA -> state.process(action)
is MainState.StateB -> state.process(action)
// ...
}
private suspend fun MainState.StateA.process(action: MainAction): MainState = when (action) {
is MainAction.ActionA -> {
// do something..
}
is MainAction.ActionB -> {
// do something..
}
// ...
else -> this
}
// ...
In addition, you can also define extension functions for MainAction.ActionA
.
override suspend fun onDispatch(state: MainState, action: MainAction): MainState = when (state) {
is MainState.StateA -> state.process(action)
is MainState.StateB -> state.process(action)
// ...
}
private suspend fun MainState.StateA.process(action: MainAction): MainState = when (action) {
is MainAction.ActionA -> process(action)
is MainAction.ActionB -> process(action)
// ...
else -> this
}
// function for MainAction.ActionA
private suspend fun MainState.StateA.process(action: MainAction.ActionA): MainState {
// not include when branches
// ...
}
Or, instead of defining an extension function for State, define an extension function for Action. This may be simpler.
override suspend fun onDispatch(state: MainState, action: MainAction): MainState = when (state) {
is MainState.StateA -> when (action) {
is MainAction.ActionA -> {
val data = action.loadData()
// describes state update process
// ...
}
// ...
}
// describe what to do for this Action
private suspend fun ActionA.loadData(): List<SomeData> {
return someRepository.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.
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 and 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 CoroutineScope
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 MainActivity : ComponentActivity() {
private val mainViewModel: MainViewModel 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(mainViewModel.store)
MyApplicationTheme {
Surface(
modifier = Modifier.fillMaxSize(),
) {
// pass the ViewStore instance to lower components
YourComposableComponent(
viewStore = viewStore,
)
// ...
when not using ViewModel
You can restore the State when changing the Activity configuration as follows:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
var state: CounterState by rememberSaveable {
mutableStateOf(CounterState.Loading)
}
val scope = rememberCoroutineScope()
val store = remember {
CounterStore(
initialState = state,
coroutineContext = scope.coroutineContext,
).apply { collectState { state = it } }
}
val viewStore = rememberViewStore(store, observe = false)
MyApplicationTheme {
// ...
In order to use the rememberSaveable
, States needs to be annotated with @Parcelize
.
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.
contens
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<MainState, MainAction, MainEvent>(
// ...
) {
override val middlewares: List<Middleware<MainState, MainAction, MainEvent>> = listOf(
// add Middleware instance to List
YourMiddleware(),
// or, implement Middleware directly here
object : Middleware<MainState, MainAction, MainEvent> {
override suspend fun afterStateChange(state: MainState, prevState: MainState) {
// 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<MainState, MainAction, MainEvent>> = 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<MainState, MainAction, MainEvent>> = listOf(
object : LoggingMiddleware<MainState, MainAction, MainEvent>(
logger = YourLogger() // change logger
) {
// override other methods
override suspend fun beforeStateEnter(state: MainState) {
// ...
}
},
)
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 -> {
send(MainMessage.LoggedOut)
}
// ...
I used Flux and UI layer as a reference for the design, and Macaron for the implementation.