Search code examples
androidandroid-jetpack-composeandroid-jetpack

Issue with Side-effects - LaunchedEffect and SideEffect in Jetpack Compose


Why SideEffect gets called ever-time my composable is invalidated , but same does-not hold true for LaunchedEffect?

sealed class SomeState {
 object Error:SomeState()
 data class Content(): SomeState
}

class MyViewModel:ViewModel {
  internal val response: MutableLiveData<SomeState> by lazy {
    MutableLiveData<SomeState>()
  }
}

// This is top-level composable, it wont be recomposed ever
@Composable
fun MyComposableScreen(
viewModel:MyVm,
launchActivity:()->Unit
){
  val someDialog = remember { mutableStateOf(false) }

  MyComposableContent()

  GenericErrorDialog(someDialog = someDialog)

  when (val state = viewModel.response.observeAsState().value) {
    // Query 1  
    is Content -> LaunchedEffect(Unit) { launchActivity() }
    Error -> {
      // Query 2
  // Gets called everytime this composable gets invalidated, for eg in case of TextField change, compiler is invalidating it.
 // But if i change it to LaunchedEffect(Unit), invalidation has no effect,LaunchedEffect only gets called when there is new update to the LiveData. why?
      SideEffect { someDialog.value = true}
    }
  }
}

// This is the content, which can be recomposed in case of email is changed
@Composable
fun MyComposableContent(
onEmailChange:(email) -> Unit,
email:String,
){
  TextField(
   email = email,
   onValueChange = onEmailChange
  )
}

I have doubts related to Query 1 and Query 2 both are part of top-level composable which will never be re-composed, but can be invalidated,

when (val state = viewModel.response.observeAsState().value) { // observing to live-data
        // Query 1  
        is Content -> LaunchedEffect(Unit) { launchActivity() }
        Error -> {
          // Query 2
          SideEffect { someDialog.value = true}
        }
      }

In case of is

Content -> LaunchedEffect(Unit) { launchActivity() }

I believe this should be fine as we want to launch an activity only when LaunchedEffect is part of the first time composition, and it will be only part of the composition if live data state is Content

I faced issue in second scenario,

Error -> {
   // Query 2
  SideEffect { someDialog.value = true // shows a dialog} 
}

If last state of live-data, is Error in viewModel. And every time i make changes in the TextField my top level MyComposableScreen was getting invalidated(not recomposed) by compose compiler, and since last state of live-data was set as error, SideEffect was running every time, which is fine as it should run for every successful composition and re-composition.

But, if i change it from SideEffect to LaunchedEffect(Unit){someDialog.value = true} dialog box was not showing up every time MyComposableScreen was invalidated, thats the desired behavior.

LaunchedEffect(Unit) gets called only if there live-data emits the new state again because of any UI-action.

But, I am not sure regarding the reasoning behind it, why the code inside LaunchedEffect(Unit){someDialog.value = true} does not trigger after composable gets invalidated but the code inside SideEffect gets triggered after composable gets invalidated?


To make it more clear

I understand the difference

SideEffect -> on every successful composition and re-composition, if it's part of it LaunchedEffect -> when its enters composition and span across re-composition unless the keys are changed.

But in above scenario - this code particularly

@Composable
fun MyTopLevelComposable(viewModel:MyViewModel){

  when (val state = viewModel.response.observeAsState().value) { // observing live-data state
    is Content -> LaunchedEffect(Unit) { launchActivity() }
    Error -> SideEffect { someDialog.value = true}
  }
}

It will never get recomposed. The only reason for this composable to be called again could be if compose compiler invalidates the view.

My Query is -> when view/composable gets invalidated

SideEffect {someDialog.value = true} executes, because it will again go through composition not re-composition as viewModel.response(which is live-data) last state was Error

But if change it to LaunchedEffect(Unit) {someDialog.value = true} it doesn't executes again after the composable is invalidated. It only reacts to a new state emitted by the live-data.

Question is why? Invalidate should start composition again, and since it's a composition. not re-composition LaunchedEffect should behave similarly to SideEffect in this scenario, as both are reacting to composition.


Solution

  • In Compose, there is no such thing as invalidating a view.

    When you keep your when in the same scope as the state variable, changing the state variable recomposes the contents of when, but when you move it to a separate composable, only updating viewModel.response can recompose it - Compose tries to reduce the number of views to recompose as much as possible.

    LaunchedEffect(Unit) will be re-run in two cases:

    1. If it was removed from the view tree during one of the previous recompositions and then added again. For example, if you wrap LaunchedEffect in if and the condition is first false and then true. Or, in your case, if when will choose Error -> after is Content ->, this will also remove LaunchedEffect from the view tree.
    2. If one of the keys passed to LaunchedEffect has changed.

    It looks like your problem is that LaunchedEffect does not restart when new content value come in, to solve this, you need to pass this value as key in LaunchedEffect, instead of Unit:

    LaunchedEffect(state) { launchActivity() }