All Articles

A Redux implementation for Kotlin Multiplatform

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

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. Reducers 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