Skip to content

This repository contains the article describing my attempt to implement a simple state reducer based on Kotlin Flow and an example app that uses it.

Notifications You must be signed in to change notification settings

linean/StateReducerFlow

Repository files navigation

All you need for MVI is Kotlin. How to reduce without a reducer?

📜 Description

This repository contains the article describing my attempt to implement a simple state reducer based on Kotlin Flow and an example app that uses it.

💡 Motivation and Context

Like any Android developer following the latest trends, I like MVI architecture and the unidirectional data flow concept. It solves many issues out of the box making our code even more bulletproof.

Alt Text

In this article, I won't go into detail about what MVI is, but you can find many great write-ups about it, e.g.

Playing with libraries like MVICore, Mobius, or Orbit inspired me to experiment and try to implement a flow that can perform state reduction.

That's how StateReducerFlow was born. Let me explain how I've built it, how it works, and how you can use it.

👨‍🎓 Thinking process

Please keep in mind that the following examples are simplified.

Let's start with a simple counter. It has one state that can be changed with two events: decrement and increment.

sealed class Event {
    object Increment : Event()
    object Decrement : Event()
}

data class State(
    val counter: Int = 0
)

class ViewModel {
    val state = MutableStateFlow(State())

    fun handleEvent(event: Event) {
        when (event) {
            is Increment -> state.update { it.copy(counter = it.counter + 1) }
            is Decrement -> state.update { it.copy(counter = it.counter - 1) }
        }
    }
}

Using the above approach, we can structure our logic in the following way:

Event -> ViewModel -> State

One issue, though, is that handleEvent can be called from any thread. Having unstructured state updates can lead to tricky bugs and race conditions. Luckily, state.update() is already thread-safe, but still, any other logic can be affected.

To solve that we can introduce a channel that will allow us to process events sequentially, no matter from which thread they come.

class ViewModel {

    private val events = Channel<Event>()

    val state = MutableStateFlow(State())

    init {
        events.receiveAsFlow()
            .onEach(::updateState)
            .launchIn(viewModelScope)
    }

    fun handleEvent(event: Event) {
        events.trySend(event)
    }

    private fun updateState(event: Event) {
        when (event) {
            is Increment -> state.update { it.copy(counter = it.counter + 1) }
            is Decrement -> state.update { it.copy(counter = it.counter - 1) }
        }
    }
}

Much better. Now we process all events sequentially but state updates are still possible outside of the updateState method. Ideally, state updates should be only allowed during event processing.

To achieve that we can implement a simple reducer using runningFold.

class ViewModel {

    private val events = Channel<Event>()

    val state = events.receiveAsFlow()
        .runningFold(State(), ::reduceState)
        .stateIn(viewModelScope, Eagerly, State())

    fun handleEvent(event: Event) {
        events.trySend(event)
    }

    private fun reduceState(currentState: State, event: Event): State {
        return when (event) {
            is Increment -> currentState.copy(counter = currentState.counter + 1)
            is Decrement -> currentState.copy(counter = currentState.counter - 1)
        }
    }
}

Now only the reduceState method can perform state transformations.

Alt Text

When you look at this example ViewModel you may notice that only the reduceState method contains important logic. Everything else is just boilerplate that needs to be repeated for every new ViewModel.

As we all like to stay DRY, I needed to extract the generic logic from the ViewModel.

That's how StateReducerFlow was born.

🚀 StateReducerFlow

I wanted StateReducerFlow to be a StateFlow that can handle generic events. I started with this definition:

interface StateReducerFlow<STATE, EVENT> : StateFlow<STATE> {
    fun handleEvent(event: EVENT)
}

Moving forward I extracted my ViewModel logic to the new flow implementation:

private class StateReducerFlowImpl<STATE, EVENT>(
    initialState: STATE,
    reduceState: (STATE, EVENT) -> STATE,
    scope: CoroutineScope
) : StateReducerFlow<STATE, EVENT> {

    private val events = Channel<EVENT>()

    private val stateFlow = events
        .receiveAsFlow()
        .runningFold(initialState, reduceState)
        .stateIn(scope, Eagerly, initialState)

    override val replayCache get() = stateFlow.replayCache

    override val value get() = stateFlow.value

    override suspend fun collect(collector: FlowCollector<STATE>): Nothing {
        stateFlow.collect(collector)
    }

    override fun handleEvent(event: EVENT) {
        events.trySend(event)
    }
}

As you can see, the only new things are a few overrides from StateFlow. To construct the flow you provide the initial state, the function that can reduce it, and the coroutine scope in which the state can be shared.

The last missing part is a factory function that can create our new flow. I've decided to go with ViewModel extension to access viewModelScope.

fun <STATE, EVENT> ViewModel.StateReducerFlow(
    initialState: STATE,
    reduceState: (STATE, EVENT) -> STATE,
): StateReducerFlow<STATE, EVENT> = StateReducerFlowImpl(initialState, reduceState, viewModelScope)

Now we can migrate our ViewModel to the new StateReducerFlow.

class ViewModel {

    val state = StateReducerFlow(
        initialState = State(),
        reduceState = ::reduceState
    )

    private fun reduceState(currentState: State, event: Event): State {
        return when (event) {
            is Increment -> currentState.copy(counter = currentState.counter + 1)
            is Decrement -> currentState.copy(counter = currentState.counter - 1)
        }
    }
}

Voilà! The boilerplate is gone.

Alt Text

Now anyone who has access to StateReducerFlow can send events to it, e.g.

class ExampleActivity : Activity() {

    private val viewModel = ViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewModel.state.handleEvent(ExampleEvent)
    }
}

That's it! Are you interested in how it works in a real app or how it can be tested? See my example project: https://github.com/linean/StateReducerFlow/tree/main/app/src

Stay inspired!

About

This repository contains the article describing my attempt to implement a simple state reducer based on Kotlin Flow and an example app that uses it.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages