How to subscribe to StateFlow in kotlin-react useEffect

Viewed 1485

I'm trying to create a small counter example for kotlin-react with functionalComponent with kotlin 1.4-M2. The example should use kotlinx.coroutines.flow. I'm struggling at collecting the values from the store in reacts useEffect hook.

Store:

object CounterModel { // Modified sample from kotlin StateFlow doc
    private val _counter = MutableStateFlow(0) // private mutable state flow
    val counter: StateFlow<Int> get() = _counter // publicly exposed as read-only state flow

    fun inc() { _counter.value++ }
}

Component:

val counter = functionalComponent<RProps> {
    val (counterState, setCounter) = useState(CounterModel.counter.value)

    useEffect(listOf()) {
        // This does not work
        GlobalScope.launch { CounterModel.counter.collect { setCounter(it) } }
    }
    
    div {
        h1 {
            +"Counter: $counterState"
        }
        button {
            attrs.onClickFunction = { CounterModel.inc() }
        }
    }
}

When I directly call CounterModel.counter.collect { setCounter(it) } it complains about Suspend function 'collect' should be called only from a coroutine or another suspend function.

How would you implement this useEffect hook?

And once the subscription works, how would you unsubscribe from it (use useEffectWithCleanup instead of useEffect)?

2 Answers

Finally found a solution. We can use onEach to do an action for every new value and then 'subscribe' with launchIn. This returns a job that can be canceled for cleanup:

object CounterStore {
    private val _counter = MutableStateFlow(0)
    val counter: StateFlow<Int> get() = _counter
    
    fun inc() { _counter.value++ }
}

val welcome = functionalComponent<RProps> {
    val (counter, setCounter) = useState(CounterStore.counter.value)

    useEffectWithCleanup(listOf()) {
        val job = CounterStore.counter.onEach { setCounter(it) }.launchIn(GlobalScope)
        return@useEffectWithCleanup { job.cancel() }
    }

    div {
        +"Counter: $counter"
    }
    button {
        attrs.onClickFunction = { CounterStore.inc() }
        +"Increment"
    }
}

We can extract this StateFlow logic to a custom react hook:

fun <T> useStateFlow(flow: StateFlow<T>): T {
    val (state, setState) = useState(flow.value)

    useEffectWithCleanup(listOf()) {
        val job = flow.onEach { setState(it) }.launchIn(GlobalScope)
        return@useEffectWithCleanup { job.cancel() }
    }
    
    return state
}

And use it like this in our component:

val counter = useStateFlow(CounterStore.counter)

The complete project can be found here. The Flow-Api is very experimental so this might not be the final solution :)

if's very important to check that the value hasn't changed, before calling setState, otherwise the rendering happens twice

external interface ViewModelProps : RProps {
    var viewModel : MyViewModel
}
val App = functionalComponent<ViewModelProps> { props ->
    val model = props.viewModel
    val (state, setState) = useState(model.stateFlow.value)
    useEffectWithCleanup {
        val job = model.stateFlow.onEach {
            if (it != state) {
                setState(it)
            }
        }.launchIn(GlobalScope)
        return@useEffectWithCleanup { job.cancel() }
    }
}
Related