Home > Software design >  How to properly use StateFlow with Jetpack compose?
How to properly use StateFlow with Jetpack compose?

Time:11-10

I'm doing a API call in the ViewModel and observing it in the composable like this:

class FancyViewModel(): ViewModel(){
 private val _someUIState =
     MutableStateFlow<FancyWrapper>(FancyWrapper.Nothing)
 val someUIState: StateFlow<FancyWrapper> =
     _someUIState

 fun attemptAPICall() = viewModelScope.launch {
  _someUIState.value = FancyWrapper.Loading
  when(val res = doAPICall()){
   is APIWrapper.Success -> _someUIState.value = FancyWrapper.Loading(res.vaue.data)
   is APIWrapper.Error -> _someUIState.value = FancyWrapper.Error("Error!")
  }
 }
}

And in composable, I'm listening to 'someUIState' like this:

@Composable
fun FancyUI(viewModel: FancyViewModel){

 val showProgress by remember {
    mutableStateOf(false)
 }
 val openDialog = remember { mutableStateOf(false) }

 val someUIState =
    viewModel.someUIState.collectAsState()
 
 when(val res = someUIState.value){
  is FancyWrapper.Loading-> showProgress = true
  is FancyWrapper.Success-> {
     showProgress = false
     if(res.value.error)
      openDialog.value = true
     else
     navController.navigate(Screen.OtherScreen.route)
    }
  is FancyWrapper.Error-> showProgress = false
 }

 if (openDialog.value){
  AlertDialog(
   ..
  )
 }

 Scaffold(
  topBar = {
   Button(onClick={viewModel.attemptAPICall()}){
    if(showProgress)
     CircularProgressIndicator()
    else
     Text("Click")
    }
   }
 ){
  SomeUI()
 }

}

The problem I'm facing is someUIState's 'when' block code in FancyUI composable is triggered multiple times during composable recomposition even without clicking the button in Scaffold(for eg: when AlertDialog shows up). Where am I doing wrong? What are the correct better approaches to observe data with StateFlow in Composable?

CodePudding user response:

If you want to process each someUIState value only once, you should put it inside a LaunchedEffect and pass someUIState as the key so that whenever it changes the block is retriggered.

val someUIState by viewModel.someUIState.collectAsState()
LaunchedEffect(someUiState) {
    when(someUiState) {
        // Same as in the question
    }
}

Alternatively, you could just collect the flow inside a LaunchedEffect.

LaunchedEffect(Unit) {
    viewModel.someUIState.collect { uiState -> 
        when(uiState) {
            // Same as in the question
        }
    }
}

CodePudding user response:

While you can use the solution provided by Arpit, I personally prefer to manage the state of the API call in the viewmodel. It is easy to abuse LaunchEffect. Also, LaunchEffect - in my opinion - should really be UI related stuff and not for handling API calls to some backend. Since you already have a variable for handling state - someUIState - only make the API call when the state is set to Nothing. Something like this:

class FancyViewModel() : ViewModel() {
    private val _someUIState = MutableStateFlow<FancyWrapper>(FancyWrapper.Nothing)
    val someUIState: StateFlow<FancyWrapper> = _someUIState

    fun attemptAPICall() = viewModelScope.launch {
        if (_someUIState.value != FancyWrapper.Nothing) {
            return
        }
        
        _someUIState.value = FancyWrapper.Loading
        
        when (val res = doAPICall()) {
            is APIWrapper.Success -> _someUIState.value = FancyWrapper.Loading(res.vaue.data)
            is APIWrapper.Error -> _someUIState.value = FancyWrapper.Error("Error!")
        }
    }
}
  • Related