Async Actions in Knot

June 16, 2020 | 5 min read
  • #  knot
  • #  android
  • #  kotlin
  • Introduction

    In this post you will learn how to perform and cancel asynchronous Actions in Knot. If you are new to Knot library, it would make sense to get to know its basic concepts before continue reading.

    Adding Actions

    Asynchronous Actions in Knot are used for performing asynchronous tasks like making network calls or accessing a database.

    The process of adding an action consists of three steps:

    Here is the code snippet, in which we declare, schedule and perform a Load action.

    sealed class Action {
        object Load : Action()
    }
    
    knot<State, Change, Action> {
        changes {
            reduce { change ->
                when(change) {
                    Change.Load -> State.Loading + Action.Load
                }
            }
        }
        actions {
            perform<Action.Load> {
                // omitted for brevity
            }
        }
    }
    

    Scheduling happens in the reducer - when returning a new state from the reducer, we just add the action to the state using plus operator. Knot will do the rest and provide the action to the corresponding perform-section.

    Performing Actions

    Perform-section contains the code responsible for performing the asynchronous action and delivering the result back to the reducer.

    It is a good practice to extract the actual implementation of a asynchronous task from the knot declaration and store it in a separate file. This keeps knot declaration clean. Here is how we can do it for the Load action.

    interface LoadItemsAction {
        operator fun invoke(): Single<Data>
        data class Data(val items: List<String>)
    }
    
    class DefaultLoadItemsAction(
        private val repository: ItemsRepository
        private val ioScheduler: Scheduler = Schedulers.io()
    ) : LoadItemsAction {
        override fun invoke(): Single<Data> =
            repository.loadItems()
                .map { it.toData() }
                .subscribeOn(ioScheduler)
    }
    

    Now we can call the implemented Load action and map the result of the action into Changes. This is needed for delivering the results back to the reducer, where we can update the state.

    perform<Action.Load> {
        flatMapSingle {
            loadItems()
                .map { Change.Load.Success(it.payload) }
                .onErrorReturn { Change.Load.Failure }
        }
    }
    

    Notice the flatMapSingle operator in the snippet above. This operator controls how multiple actions are handled, when we send them to the perform-section.

    FlatMap

    By using the flatMapSingle operator, we instruct Knot to perform all actions independent of each other. Each new action will be started immediately. Because the operator does not care about the order of the items, the actions will emit their resulting Changes as soon as they are performed. Thus, the result Changes of different actions may interleave.

    This operator is safe to use when the number of Actions running in parallel is controlled in the reducer.

    In our example, we schedule the Load action and also move the state machine into State.Loading, where all subsequent Change.Load requests are ignored. We are on the safe side here, because we guarantee that the Load action will be performed exactly once at a time.

    ConcatMap

    If the order of performed actions is important, then the concatMapSingle operator should be used. A use case for it could be some kind of a Store action, which adds a new entry to the database each time the action is performed.

    SwitchMap

    As the operator suggests, when switchMapSingle is used, each newly scheduled action will cancel previously scheduled action, if the latter is still running.

    This operator is used when we want to ensure that a single action runs at a time and we allow a new action to cancel the previous one. We can also use it for implementing cancellable actions, which we will cover in the next section in more details.

    Cancelling Actions

    When the knot instance is not needed anymore - for example, when its hosting ViewModel gets cleared - we should explicitly dispose that knot instance by calling knot.dispose() method. Disposed knot instance cancels all running actions, ensuring that everything is cleaned up and no resources are leaked.

    Sometimes, however, we will need to cancel just a single running action without disposing the whole knot instance. This can be achieved by using already mentioned switchMapSingle operator, thanks to its ability to unsubscribe from the previous Single, when a new Single is generated. What we need to do is send an additional cancellation flag with the action to the switchMapSingle operator and return a no-operation Single from the operator, if the cancellation flag was set. Returned no-operation Single will unsubscribe and dispose previously launched loading Single, which is exactly what we wanted to achieve.

    Here is the implementation of this approach.

    sealed class Action {
        data class Load(val cancel: Boolean = false) : Action()
    }
    
    knot<State, Change, Action> {
        changes {
            reduce { change ->
                when(change) {
                    Change.Load -> when(this) {
                        State.Empty -> State.Loading + Action.Load()
                        else -> only
                    }
                    Change.Cancel -> when(this) {
                        State.Loading -> State.Empty + Action.Load(cancel = true)
                        else -> only
                    }
                    Change.Load.Success -> when(this) {
                        State.Loading  -> State.Content(change.payload)
                        else -> only // ignore, action was cancelled
                    }
                    ...
                }
            }
        }
        actions {
            perform<Action.Load> {
                switchMapSingle { action ->
                    if (action.cancel) Single.never()
                    else {
                        loadItems()
                            .map { Change.Load.Success(it.payload) }
                            .onErrorReturn { Change.Load.Failure }
                    }
                }
            }
        }
    }
    

    Wrap-up

    I hope this post was helpful for you and you can master asynchronous Actions in Knot with ease now.

    Practice more and happy coding! ✌