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.
- Works on multiple platforms (currently on Android and iOS).
- Therefore, code can be shared across platforms.
The architecture is inspired by Flux and is as follows:
The core functionality of the Store can be represented 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),
)
Override 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'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 onEnter()
, 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().
If omitted, only the Dispatcher.Default
thread will be used without inheriting the context.
Specify it when you want to match the Store's Coroutines lifecycle with another context or change the thread on which it operates.
This callback can handle uncaught errors. For example, logging can be done 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 do not specify a context that is automatically disposed like ViewModel's viewModelScope
or Compose's rememberCoroutineScope()
in the constructor of the Store, call Store's .dispose()
explicitly when the 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:
@HiltViewModel
class CounterViewModel @Inject constructor(
counterRepository: CounterRepository,
) : ViewModel() {
val store = CounterStore(
counterRepository = counterRepository,
)
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
YourComposable(
viewStore = viewStore,
)
// ...
You can create a ViewStore
instance without using ViewModel as shown below, but note that States must implement Parcelable
or Serializable
because they are used internally for rememberSaveable.
@AndroidEntryPoint
class CounterActivity : ComponentActivity() {
@Inject
lateinit var counterRepository: CounterRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// create an instance of ViewStore
val viewStore = rememberViewStoreSaveable { savedState: CounterState? ->
CounterStore(
counterRepository = counterRepository,
initialState = savedState ?: CounterState.Loading,
)
}
// ...
Alternatively, you can prepare a StateSaver and handle the persistence yourself. For details, see Introduction to rememberViewStoreSaveable in Tart 1.3.0.
TIPS: Preparing a Store Factory class
Like Repository and UseCase, if there are many dependencies that need to be passed to the Store constructor, it is better to prepare a factory class as follows:
// provide with DI libraries
class CounterStoreFactory(
private val counterRepository: CounterRepository,
private val userRepository: UserRepository,
private val logger: YourLogger,
) {
fun create(initialState: CounterState): CounterStore {
return CounterStore(
counterRepository = counterRepository,
userRepository = userRepository,
logger = logger,
initialState = initialState,
)
}
}
// ...
@AndroidEntryPoint
class CounterActivity : ComponentActivity() {
@Inject
lateinit var counterStoreFactory: CounterStoreFactory
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// create an instance of ViewStore
val viewStore = rememberViewStoreSaveable { savedState: CounterState? ->
` counterStoreFactory.create(
initialState = savedState ?: CounterState.Loading,
)
}
// ...
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> {
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 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 {
YourComposable(
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.