https://kotlinlang.org logo
#mvikotlin
Title
# mvikotlin
s

Seth Madison

02/12/2023, 10:46 PM
I wish there was an easier way to manage cancellation of async tasks on state changes. Imagine something like:
Copy code
sealed interface State {
    data class PersonLoading(val id: String) : State
    object PersonProfile(val name: String) : State
}

sealed interface Intent {
    data class LoadPerson(val id: String) : Intent
}

sealed interface Msg {
    data class PersonLoading(val id: String) : Msg
    object PersonLoaded(val name: String) : Msg

}
fun executeIntent(intent: Intent) {
    when (intent) {
        is Intent.LoadPerson -> {
            dispatch(Msg.PersonLoading(intent.id))
            val name = someAsyncFunction(intent.id)
            // HOW DO I CHECK WHETHER THE PERSON I'M LOADING HAS CHANGED HERE???
            dispatch(Msg.PersonLoaded(name))
        } 
    }
}
I’ve seen two approaches to this problem: (1) After you do anything async you retrieve the current state using
getState
and check to make sure that the data hasn’t changed (eg add
requestId
to state, or if your operation has a stable id like in the example above, use that). (2) Create child scopes (or disposables) that are stored in state, and get cancelled on state transitions. So (1) might look like:
Copy code
fun executeIntent(intent: Intent, getState: () -> State) {
    when (intent) {
        is Intent.LoadPerson -> {
            dispatch(Msg.PersonLoading(intent.id))
            val person = someAsyncFunction(intent.id)
            val state = getState()
            if (state is PersonLoading && state.id == person.id) {
                dispatch(Msg.PersonLoaded(name))
            }
        }
    }
}
And (2) might look like:
Copy code
sealed interface State {
    val scope: CoroutineScope
    data class PersonLoading(override val scope: CoroutineScope, val id: String) : State
    data class PersonProfile(override val scope: CoroutineScope, val name: String) : State
}

sealed interface Intent {
    data class LoadPerson(val id: String) : Intent
}

sealed interface Msg {
    data class PersonLoading(val scope: CoroutineScope, val id: String) : Msg
    data class PersonLoaded(val scope: CoroutineScope, val name: String) : Msg
}
fun executeIntent(intent: Intent) {
    when (intent) {
        is Intent.LoadPerson -> {
            dispatch(Msg.PersonLoading(scope.createChildScope(), intent.id))
            val name = someAsyncFunction(intent.id)
            // HOW DO I CHECK WHETHER THE PERSON I'M LOADING HAS CHANGED HERE???
            dispatch(Msg.PersonLoaded(scope.createChildScope(), name))
        }
    }
}

fun CoroutineScope.createChildScope(): CoroutineScope {
    val ctx = coroutineContext
    return CoroutineScope(ctx + Job(ctx.job))
}


private object ReducerImpl : Reducer<State, Msg> {
    override fun State.reduce(msg: Msg): State {
        scope.cancel()
        when (msg) {
            // etc
        }
    }
}
(1) Feels cumbersome, but more correct. (2) Feels cleaner, but not well supported by the framework. Is there preferred approach? Am I thinking about this wrong? I feel like it would be very nice if the framework provided a way to manage (2) cleanly without sprinkling scopes all of your state, and managing their cancellation in the reducer. Like, what if you were given a
stateScope
that was guaranteed to be a child scope, and that you could cancel in the reducer conditionally? Maybe there’s a better way?
a

Arkadii Ivanov

02/13/2023, 8:35 AM
You can check the current state in the reducer and either apply the Msg or drop it. You can also store a Job in an Executor's private property or in the State and cancel it when needed. Would this work?
s

Seth Madison

02/13/2023, 8:45 AM
Yes, either would work, though I’ve been trying to stay away from keeping state in the executor. I guess the broader point is that I wish the framework were opinionated about the best way to handle this, since I feel like it is a very common use-case. I don’t have a constructive idea on how that might look, so perhaps this is not very useful feedback.
It feels like right now it is easy to make mistakes, and/or you end up with a lot of code that looks like:
Copy code
doSomeAsyncWork
if (somethingChanged){
  bail
}
a

Arkadii Ivanov

02/13/2023, 8:48 AM
In case of a simple loading, there is often no need to cancel the job. Just check the state in the reducer before applying Msg. Cancellation is required for long-running subscriptions, which I find a not-so-often case. I usually store a Job in the Executor.
Executor is stateful by design, and checking the new state after an async task is required if you need to conditionally perform additional tasks. Without being able to read the current state, the Store implementation would be more boiler-plate, like in MVICore.
Bot for simple async tasks you don't need this. Just check the state in the reducer.
s

Seth Madison

02/13/2023, 8:55 AM
That all makes sense — I guess the only question is whether it is a bad idea to store the job (or the child scope?) in the state rather than on the executor? It seems nice to be able to cancel it in the reducer on a state transition rather than relying on logic in the executor for that.
a

Arkadii Ivanov

02/13/2023, 8:56 AM
Agree, there are no guidelines here. I usually store it in the Executor, but storing it in the Reducer should also work. Both ways look good to me!
s

Seth Madison

02/13/2023, 8:57 AM
Cool cool.
Thank you for all your work on this project, it is such a pleasure to use!
a

Arkadii Ivanov

02/13/2023, 8:58 AM
Thanks for the feedback! 🙌
3 Views