Business logic is usually platform independent but UI state management is always influenced by the platform you are building on. In this post, I will outline how I implemented a Redux inspired state management solution for my recent Kotlin multiplatform project and how it is connected to UI on Android and iOS.
Redux components
We can describe any user screen with 3 type definitions regardless of the platform we are targeting:
State
describes what’s being rendered on the screen. For example, showing/hiding a loading indicator.Action
describes what a user can do on the screen. For example, a click of a button.Effect
which are side effects that are triggered usually by an action. For example, making an API request when a user clicks a button.
With these definitions in place, we need the machinery to tie everything together. That’s where Store
and Reducer
are useful.
Store
is a container for the current state, it’s also responsible for updating listeners when this state changes.
Usually state updates happen when an action is performed or after a side effect has been executed.
Reducer
is a processor of actions. It takes in the current state and the requested action then uses them to compute a new state.
State listener don’t have to know about the reducer, we can consider it an implementation detail of the store. The Store
usually exposes a way to request actions and internally forward it to
a Reducer
for processing.
Kotlin implementation
Since our idea describes a system that should work for any screen, our implementation also needs to be flexible to work with any screen. That’s why the first place to start is to settle on the fact that we will be using generics.
Defining Store
Let’s first examine the Store
’s requirement.
- It needs to hold the most recent version of the state
- It should allow consumers to observe or stop observing state changes
- It should allow consumers to execute actions
These requirements can be described in the following interface
// Note: don't worry about `StateContainer` for now, we will discuss it further down in the post.
interface StateObserver<STATE, EFFECT> {
operator fun invoke(stateContainer: StateContainer<STATE, EFFECT>)
}
interface StateStore<STATE, ACTION, EFFECT> {
val currentState: StateContainer<STATE, EFFECT>
fun addObserver(observer: StateObserver<STATE, EFFECT>)
fun removeObserver(observer: StateObserver<STATE, EFFECT>)
suspend fun dispatch(action: ACTION)
}
This system will be used on mobile platforms. Which means that background apps can be terminated to save memory and screens can be rebuilt when orientation changes.
So we will need a mechanism to restore the screen into a given state, a process will call rehydration. Basically it’s a way for us
to set the State
inside StateStore
without invoking an Action
.
interface Rehydratable<T> {
fun rehydrate(value: T)
}
interface StateStore<STATE, ACTION, EFFECT>: Rehydratable<STATE> {
val currentState: StateContainer<STATE, EFFECT>
...
}
I could go down the route of having an Action
to rehydrate the Store
but that for would force me to use some form of
abstract class BaseReducer
to handle the common rehydrate action or force me to repeat the rehydration logic across reducers.
Defining side effects
There are two types of side effects:
- non UI Side effects, such as making an API request or updating a database when a button is tapped.
- UI side effects such as showing a message to a user, a
Toast
on Android, while performing a long-running operation.
Non UI side effects are usually handled within the reducer. For example, launching a coroutine to write to the database. whether it’s fire and forget or will require a state update is not for the state store to decide.
UI side effects on the other hand need to be propagated to the observing UI. It is the responsibility of our state management logic to ensure that UI side effects will only fire once.
That’s why I’ve added Effect
class which tracks whether the side effect has been consumed or not effectively notifying listeners once.
class Effect<EFFECT>(private val effect: EFFECT?) {
private var isConsumed = false
operator fun invoke(block: EFFECT.() -> Unit) {
if (!isConsumed) {
effect?.block()
isConsumed = true
}
}
}
I’ve also coupled UI side effect propagation with state updates in StateContainer
which is a simple data class that holds the state and any UI related side effects.
data class StateContainer<STATE, EFFECT>(
val state: STATE,
val effect: Effect<EFFECT>
)
Defining Reducer
As I mentioned before, a Reducer
is a processor that takes in the current state and a given action then uses them to compute
a new state. I’ve also added another requirement for any Reducer
to handle its own logic related side effects. With those requirements in mind, we can define Reducer
using the following interface
interface StateReducer<STATE, ACTION, EFFECT> {
suspend fun reduce(
action: ACTION,
currentState: STATE,
dispatch: suspend (ACTION) -> Unit = {}
): StateContainer<STATE, EFFECT>
}
It is a simple interface with a reduce
function to be implemented. It requires a dispatch
lambda to be able to send additional actions to the store and the reduce functions is a suspending function to be able to execute async logic
like network calls and invoke dispatch
.
Connecting the platforms
When connecting UI to Redux, we usually have two ways. Either having single or multiple stores. Personally, I like using a store per feature.
For example, HomeScreenStore
and LoginScreenStore
. The data layer will hold any shared logic or data for all stores to access.
the data layer.
The implementation of StateStore
shouldn’t change whether you decide on single or multiple stores. Here’s a default implementation that I use
across all features. It has its own coroutines scope, and it keeps track of launched jobs for observing state updates. It is able to remove an observer by cancelling the job
associated with it.
class DefaultStateStore<STATE, ACTION, EFFECT>(
private val reducer: StateReducer<STATE, ACTION, EFFECT>
) : StateStore<STATE, ACTION, EFFECT>, CoroutineScope {
private val mutableState = MutableStateFlow(
StateContainer(
reducer.initialState,
Effect<EFFECT>(null)
)
)
private val observingJobs = mutableMapOf<StateObserver<STATE, EFFECT>, Job>()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main
override val currentState: StateContainer<STATE, EFFECT>
get() = mutableState.value
override fun addObserver(observer: StateObserver<STATE, EFFECT>) {
observingJobs[observer] = mutableState
.onEach { observer(it) }
.launchIn(this)
}
override fun removeObserver(observer: StateObserver<STATE, EFFECT>) {
observingJobs[observer]?.cancel()
}
override suspend fun dispatch(action: ACTION) {
mutableState.emit(
reducer.reduce(
action,
mutableState.value.state,
dispatch = { dispatch(action) }
)
)
}
override fun rehydrate(value: STATE) {
mutableState.value = StateContainer(value, Effect(null))
}
override fun reset() {
mutableState.value = StateContainer(reducer.initialState, Effect(null))
}
}
You can also decide to pass your own coroutines scope or opt into making addObserver
a suspending function but that might
make it harder to consume the store from Swift code.
I also like to treat state stores as singletons and expose them via an object
.
object Stores {
val loginStore = DefaultStateStore(reducer = LoginReducer(...))
}
Integrating with iOS
Although this Redux implementation can work with both UIKit and SwiftUI. My main focus will be on SwiftUI for this section.
The main aim is to get SwiftUI to update the screen whenever the state changes and to do that we have
to bridge the gap between our StateStore
and SwiftUI.
Since our view state will be coming from an external object, StateStore
instance, then this object needs to extend ObservableObject
and publish the state using @Published
property wrapper.
class StateStoreWrapper<STATE : AnyObject, EFFECT: AnyObject, ACTION: AnyObject> : ObservableObject, StateObserver {
private let store: StateStore
@Published var state: StateContainer<STATE, EFFECT>
init(store: StateStore) {
self.store = store
self.state = StateContainer(
state: (store.currentState.state as! STATE),
effect: (store.currentState.effect as! Effect<EFFECT>)
)
}
func start() {
store.addObserver(observer: self)
}
func stop() {
store.removeObserver(observer: self)
}
func invoke(stateContainer: StateContainer<AnyObject, AnyObject>) {
state = StateContainer<STATE, EFFECT>(
state: (stateContainer.state as! STATE),
effect: (stateContainer.effect as! Effect<EFFECT>)
)
}
func dispatch(action: ACTION) async throws {
try await store.dispatch(action: action)
}
}
You will notice that the wrapper is also a StateObserver
to be able to observe state updates from the store. We can also create
a handy extension to wrap any store in our StateStoreWrapper
implementation.
extension StateStore {
func wrap<STATE : AnyObject, EFFECT: AnyObject, ACTION: AnyObject>() -> StateStoreWrapper<STATE, EFFECT, ACTION> {
StateStoreWrapper(store: self)
}
}
If you would like to know more about SwiftUI and its state management intricacies then Hacking with Swift have a great guide for it.
Integrating with Android
Similar to iOS, we need a wrapper for StateStore
to be able to observe state updates and expose a state stream for our views/composables.
The wrapper also will allow us to hook into platform specific features like saving and reading the state from SavedStateHandle
.
class StateStoreWrapper<STATE, ACTION, EFFECT>(
private val savedStateHandle: SavedStateHandle,
private val stateStore: StateStore<STATE, ACTION, EFFECT>
) : ViewModel(), StateObserver<STATE, EFFECT> {
init {
val savedState: STATE = // TODO: Read stored state from savedStateHandle
stateStore.rehydrate(savedState)
}
private val mutableState = MutableSharedFlow<StateContainer<STATE, EFFECT>>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val state get() = mutableState.asSharedFlow()
override fun invoke(stateContainer: StateContainer<STATE, EFFECT>) {
//TODO: saved new state using savedStateHandle
viewModelScope.launch { mutableState.emit(stateContainer) }
}
}
Wrapping up
Redux for me is a great fit for both Jetpack compose on Android and SwiftUI on iOS. We use DefaultStateStore
to provide the required infrastructure to maintain the most recent version of the state, update listeners when state changes and delegate actions to reducers.
Reducer
s are where actions are processed. It’s the integration point between UI screen and the business logic and in our application I have one reducer per screen, for example, HomeScreenReducer
& AccountScreenReducer
… etc. This makes UI state management is predictable and consistent across all screens.
[1] Image source: https://dev.to/radiumsharma06/abc-of-redux-5461