Hello! I did a search but couldn't find anything ...
# compose
n
Hello! I did a search but couldn't find anything - so if this is a duplicated question, or in the wrong place, please point me in the right direction 👍 ViewModel state handling in KMP/CMP (well, and Android). I love ViewModel, it's just great - and I am using it with NavHost and Koin; no issues there. Pretty and neat. I've used the pattern of setting up initial state and data in the ViewModel with the
init{}
function for a long time; but with the not so latest chatter; it's "obvious" that we should use
.stateIn()
instead (one source, I've seen pleanty). And I buy the premise! I like the constructor not tied to logic part for testability. The argument that if there have been no one listening for over 5 seconds then, you can do another fetch (because Android ANR) etc. But, I've failed to see any larger working example! It's all nice and well to present the logic on a Read-only screen with a pokemons data, but I am writing more advanced interaction than that. The issue that I've found is that I don't always have an incoming flow (UseCase/repo) that I can manipulate to update UI State (I prefer a single state). I want to be able to update the state, ie it should be an available
MutableStateFlow<State>
. I want to be able to click the checkbox and have the ViewModel update the UI so the button is now enabled, but not having to have an entire state flow or use case just to combine a bunch of flows in order to update the state. I "need"
flow.update{ it.copy(buttonEnabled = true)}
. Abstracted ViewModel code:
Copy code
val stateFlow: StateFlow<S> = mutableStateFlow
    .onStart { onStateFlowStart() }
    .stateIn(
        scope = coroutineScope,
        started = SharingStarted.WhileSubscribed(TIME_BEFORE_RESTART),
        initialValue = state
    )
If I do a single fetch to get the data; then this works fine. Every time the stateFlow val gets collected for the first time in 5 seconds - then I can set
onStateFlowStart()
to fetch and update the
mutableStateFlow
value with
.update{}
. But if I need to observe to a UseCase, or two (but combine could solve that), that returns a Flow - then I can't collect that in "on start" and update the state flow; I would start collecting multiple times. So, how can I achieve: (all or some of the points below) • "Proper handling" that have a lot of benefits (I'm not saying all, because it might no be applicable at all times?) • An available mutable state flow to update only the UI state • The benefits of stateIn with it's WhileSubscribed logic. • Readable and understandable code. How are you handling things in actual, complex ViewModels?
I found this as a side note in this

video by Philipp Lackner

Copy code
Little addition: Depending on your situation you may want to use SharingStarted.Lazily instead of WhileSubscribed, especially if you want to keep the value of the flow when the app goes in the background
So maybe it's just that, stateIn Whilesubscribed is not meant for all cases 🤷‍♂️
g
Dont know if it's possible on your side, but my screens are always observing flows from Room (offline support, reactive screens, one source of truth). Thanks to that, what ever I trigger in the onStart will update the data in the data source. My uiState is always a combine of multiple flows (mixing data from data source and user interactions for things I don't persist, like a check box)
Copy code
private val isCheckedFlow = MutableStateFlow<Boolean?>(null)

val viewState: StateFlow<ScreenViewStateUiModel> =
    combines(
        observeDataAUseCase(),
        observeDataBUseCase(),
        isCheckedFlow,
    ).mapLatest { (dataA, dataB, isChecked) ->
        toScreenViewState(
            dataA = dataA,
            dataB = dataB,
            isChecked = isChecked,
        )
    }.onStart {
        fetchDataAUseCase()
    }
        .stateIn(...)

fun onChecked(isChecked: Boolean) {
     isCheckedFlow.value = isChecked
}
n
And that looks really nice, I like it ^^ Except for the isCheckedFlow, but maybe that's something to just get used to 🤷‍♂️ I've grown accustomed to my
updateState {copy() }
function I have in my ViewModels, but maybe it's holding me back 🤔
Maybe I want to update more data on screen based on some quick calculation before saving data to the database; + checked + more?
g
Well, to be honest, the updateState{copy()} becomes a nightmare when the VM grows and becomes complex. Having the ui state being computed at only one place is a such a bless. I've been using combines for ages now and things are ez pz, even for complex VMs. Any user interactions that updates the states has a flow, that's the key. when leaving the screen, you can persist or send whatever data!
f
Hi @Guillaume B I was just about to ask about how do you overcome the
combine
limitation of the max amount of flows. But I noticed that you use a
combines
that I'm not aware of. Is that some kind of util function?
g
hey @Fernando indeed, that's the downside of it, I have util class for that... Goes up to 20 flows (thats largely enough, and if you need more, then maybe it's time to cut the viewmodel into smaller ones)
z
i mildly regret not adding one that expects a function and not map into an intermediary tuple first, but yes, it is a very useful library to this day imo
also had one for Rx and one for LiveData, although it's obvious which one is more popular
Well, to be honest, the updateState{copy()} becomes a nightmare
idk why anyone did
updateState(copy()
and is also the primary reason why i have historically always advocated against each and every form of "MVI"
because MVI was just a wrapper over
(State) -> State
that would be then passed to like,
updateState(updaterFunction)
in a queue and that was "The Architecture" like, guys this is not rocket science or at least it wasn't until you made it that way lol
g
haha yupe^^
z
so much more extra effort compared to just editing a MutableStateFlow and combine literally handles everything immediately without any magic and you don't accidentally overwrite values or forget to set up values etc
MVI is almost like a herculean effort to turn reactive code into procedural C
for no benefit
anyway, just use combine 👍
g
"i mildly regret not adding one that expects a function and not map into an intermediary tuple first, but yes, it is a very useful library to this day imo" => what would be the added value here ? you think that would be useful ? Can't think of a use case for that
z
just that some people complain about that "i just wanted combineMap and not combineTuple, i don't need your tuple i already have my own class" and then say that an extra instance for a combine.map is just "too much" (same people store 70 screens of info in singletons because architecture, so idk)
when you talk to people who complain about everything even more than i've ever complained about anything historically (which is a feat) then it would be useful, i could do it with a simple replace regex but then never did 😅
anyway it would have just been
combineMap(a, b, c, d) { MyOwnClass(a, b, c, d) }
so it is not a tuple inbetween
g
haha yeah ok I see your point
n
This is a very interesting topic to follow, thank you so much for your responses! 😄 We're in a position right now were we're rebuilding the entire app from scratch, and want to get a solid foundation and convention in place. So maybe it's time to try without
stateFlow.update{}
g
huhu a full rewrite! man you're lucky to have the green light from above^^ well then yeah, ditch the stateflow.update thing and never use it again!! store everything in DB and use it as your source of truth, screens full reactive + offline support out of the box
z
(not all apps need offline support, for example the banking apps i generally have to write, they're not allowed to "show outdated data" and it's already an issue if you do a transfer for example and the balance isn't updated when user sees it etc.)
g
Yeah of course depending on the product need, you can just use an in memory data source instead of room
but observing from a flow what you show on screen is always better than having to get manually and update the view state each time
z
this is definitely true, and which is why we've historically done a lot of in-memory data sharing between screens and observe flows from shared screen models scoped to a ui-flow etc
n
@Guillaume B in your library you have
stateIn
function that always use Eagerly SharingStarted. What's the difference between Eagerly and Lazily - I mean from a practical standpoint, why did you go with Eagerly? Does that mean it start the flow as soon as it's created - which would be the same (~ish) as doing a bunch of stuff in the init? (And why not WhileSubscribed?)
g
hmm that's probably something I need to update, the best is to use WhileSubscribed (eagerly or lazily means that collection will continue while app is in background for instance or the screen is in the backstack, most of times you don't want that behavior) Doing stuff in the init is seen as bad practice for several points: • really hard to test • object creation shouldn't rule flows in general • at some point compose will enable multi-threaded composition and starting work for compositions that are going to be ditched right away is not really something you want
z
WhileSubscribed(5000) is fairly standard
1
while i have seen people try to put such initialization logic behind a
by lazy {}
accessor triggering it, it's rather rare to actually see it
there were also Google guides that basically said do a
LaunchedEffect { blah.initialize() } but that design is honestly almost worse than just putting it in an init {} block
😂 1
d
I've also tried to avoid
.stateIn()
historically but have a chance to start fresh now and wondering if I'm missing out - how does
WhileSubscribed
behave when you navigate "back" to a screen+vm that uses it? I was under the impression that you'd lose the state that the VM was and it would have to go through loading/fetching the data again?
z
tried to avoid... why exactly? 🤔
the while subscribed just dismantles the collectors, it won't drop the value in the stateflow afaik
g
@dorche stateIn transform a cold flow (regular Flow) to a hot flow (StateFlow). What do you use otherwise ? WhileSubscribed(5000) means that if you leave the screen (lets say navigate to another screen), stay on it more than 5s, and then goes back to the previous screen, the onStart will be triggered again
but like Gabor said, the last state emitted wont be dropped, it will be re emitted when collecting starts again
You shouldn't avoid .stateIn imo, it's your best friend😅
d
I guess I've been fairly comfortable with just having something like this (assuming the app doesn't need offline functionality, e.g. banking app like Gabor mentioned)
Copy code
val state = MutableStateFlow(UiState.Initial)

fun refresh() {
  viewModelScope.launch {
    // fetchData is your usecase call or whatever
    val response = fetchData()
    state.update {
      ...
    }
  }
}
And letting my Compose code call refresh on (first) screen view. This has a couple benefits imo • assuming you can get away with it, usecase and repository layer can be simple suspend functions and not flows. Makes them simpler and easier to test. • In cases where I want the screen to not refresh/retrigger the network calls when I navigate back to it it's fairly easy - controlled by how often you call
refresh()
from the compose side. Same if you're consuming this ViewModel from SwiftUI. I appreciate the design of having to trigger this function from Compose is not ideal but it seems the most flexible. As I said my very quick look into
stateIn()
(this was a while ago so I could be wrong) led me to believe that it will force you to retrigger
onStart
regardless if you're navigating back or for the first time and also my presumption was that
WhileSubscribed(5000)
will "destroy" (for lack of better word) the current value in the StateFlow.
z
this doesn't have much to do with online vs offline, it's just a lot of effort to make the code non-reactive
d
So just trying to understand if I have that last part wrong. I appreciate that not everyone will like the alternative approaches and their caveats, that's a separate topic imo
z
Copy code
private val response = MutableStateFlow<Response>(null)
private val otherFlow = MutableStateFlow<...>(...) // some might be coming from savedStateHandle.getStateFlow()
private val etc = MutableStateFlow<...>(...)

val state = combineTuple(response, otherFlow, etc).map { (response, other, etc) ->
    when {
        response == null || response.isLoading -> UiState.Loading
        else -> UiState.Content(
            response = response,
            other  = other,
            etc = etc,
        )
    }
}.stateIn(viewModelScope, WhileSubscribed(5000L), UiState.Loading)
g
that's where a cache logic needs to be implemented somewhere @dorche
the screens just says "hey i'm on screen, i'm observing the data", the VM says "ok emitting and let's make sure to refresh that data", the cache logic says "hmm you just fetched it like 2mins ago, ignoring your request, if you want to force it tho, set the param forceRefresh to true"
d
Yeah I appreciate you could work around some of the "quirks" I mentioned. Although even with cache I assume you might get at least 1 frame of Loading/flicker which I wouldn't really like. I think this in-directly answers my questions tho.
stateIn(WhileSubscribed(5000)
+
onStart {}
indeed has it's own quirks that one might need to work around depending on their requirements.
g
Well then you can use Eagerly and onStart won't be triggered again when navigating back.
z
don't have the navigation action in your state or immediately cancel it out before navigating and you should be ok
n
Well, I'm hooked and I've set up my abstract StateEventViewModel with a helper function to always achieve this setup. The selling point was for me was to only have exactly one point to drive/decide/map the ui state.
Copy code
override val stateFlow: StateFlow<State> = viewModelState(
    data = {
        combines(
            flowOf("one"),
            flowOf("two"),
            flowOf("three"),
        )
    },
    state = { (one, two, three) ->
        State(
            title = one,
            text = two,
            buttonLabel = three
        )
    },
    onStarted = {
        fetchThatUpdatesSources123()
    }
)
So we're probably going to stick with this and figure out any issues. But once I am getting into the mindset I have a huge screen in another project that would become so much easier by just introducing a shared flow state; instead of the current "make sure that the sub-viewmodel have access to the updateState{} function but in a smaller scope". Can't wait to put it in there!
s
^ works well in most cases. But when you do not want to refresh when navigating back, this fails IMO and I personally don't like complicating cache with "ignore this call because you refreshed x time ago. Set force to true" pattern. It makes debugging harder; you need to understand cache details and just looking at viewmodel code doesn't tell you the correct picture. Instead I use channels for this case. (Apologies for pseudo code as I am on phone)
Copy code
val refreshChannel = Channel<Unit>()
val dataChannel = Channel<Data>

val uiStateFlow = combineTuple(dataChannel,...).stateIn(WhileSubscribed, UiState.Loading)

init {
   scope.launch {
     refreshChannel.collect {
         dataChannel.emit(repo.fetchData())
      }
   }
   refreshChannel.trySend(Unit)
}

fun userRequestedRefresh() = refreshChannel.trySend(Unit)
This also helps me avoid Data being nullable and I can request refresh whenever I want
g
Ok but when do you call userRequestedRefresh() ?
You just added some code to collect the refresh order...
s
Ok but when do you call userRequestedRefresh() ?
Whenever the user wants to refresh the data, for example pull to refresh, retry button in error state, etc.
g
And when you enter the screen? What do you do?
Would you go with LaunchedEffect? VM init block? Stateflow onStart?
A cache mechanism is sometimes the way to go
s
There is no launchedEffect needed The VM init block is collecting the refreshChannel and performs the initial refresh
g
Ah yes sorry didn't see the trysend there
So yeah in the VM init block
s
Your state flow is not being tied to init block here. You still use WhileSubscribed, stop collection when no subscribers are present. Just difference of opinions where init block is evil vs not evil 🤷
g
But again there's no best solution, we should choose what's fits best the product specs
💯 1
State flow is never tied to the init block, we never said that, we're talking about when to trigger a refresh of data when entering the screen
z
I'll never agree with the idea of having to do
LaunchedEffect(Unit) { viewModel.actuallyInitializeMeow() }
being better, because it's extremely easy to accidentally not call it as it's a non-intuitive non-idiomatic api; even if Google says so
Google has said many things, this will be one of the things that'll eventually end up altered one way or the other
I've seen people hack a side-effect into a by lazy accessor wrapper over ui state, I'm not entirely convinced but at least you can't "not call it".
BTW that was also Google, just on Twitter and not the docs iirc
s
Nobody talked about using launchedEffect. The alternative given also doesn't rely on launchedEffect
z
IMO this is kind of a ViewModel oversight, in simple-stack this is why we had
OnServiceRegistered
to move unit init logic to a proper "start-up place"
But obviously that won't help regular androidx users
g
The other pb with channel @Saurabh Arora is that combines() waits for all flows to emit something. So if whatever you're doing in the fetch data is taking like 10s, you won't have any state emitted until 10sec, while other flows might have emitted their data, and potentially render other parts of the screen. I don't have issues dealing with null values (or sealed classes) and prefer to use stateflows as they require an initial value, which won't block the combines emition
s
Ah for sure. Channel for data was just in this example. if the UI is okay with partial data, then definitely prefer StateFlow
👍 1