Nino
04/13/2026, 10:14 AMNino
04/13/2026, 10:15 AMclass MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SnackbarBugTheme {
val scope = rememberCoroutineScope()
val snackBarHostState = remember { SnackbarHostState() }
val viewModel = viewModel<MainViewModel>()
val errorState by viewModel.errorStateFlow.collectAsStateWithLifecycle()
LaunchedEffect(errorState) {
if (errorState != null) {
scope.launch {
val result = snackBarHostState.showSnackbar(
message = errorState!!,
actionLabel = "Retry",
)
when (result) {
SnackbarResult.Dismissed -> TODO()
SnackbarResult.ActionPerformed -> viewModel.onRetry()
}
}
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(hostState = snackBarHostState) },
) { innerPadding ->
Text(
modifier = Modifier.padding(innerPadding),
text = "Android",
)
}
}
}
}
}
MainViewModel.kt
class MainViewModel : ViewModel() {
private val errorMutableStateFlow = MutableStateFlow<String?>("Error")
val errorStateFlow = errorMutableStateFlow.asStateFlow()
fun onRetry() {
viewModelScope.launch {
errorMutableStateFlow.value = null
// Let's say it took less than a Compose frame (16.6ms) to go from "loading" (null) to
// an error ("Error")
// It causes the snackbar to not reappear because for Compose, errorStateFlow never
// changed its value...
delay(5.milliseconds)
errorMutableStateFlow.value = "Error"
}
}
}Nino
04/13/2026, 10:16 AMshowSnackbar is no longer suspending), but it's never re-displayed...Nino
04/13/2026, 10:18 AMdelay(17) is obviously not a viable option. Any alternative? Or fix incoming?Nino
04/13/2026, 10:46 AMdirty flag in my class to force the LaunchedEffect to be recomposed. That's awful. There's no other options?
class MainViewModel : ViewModel() {
private val errorMutableStateFlow = MutableStateFlow(
ErrorUiState(
dirty = 0,
message = "Error",
)
)
val errorStateFlow = errorMutableStateFlow.asStateFlow()
fun onRetry() {
viewModelScope.launch {
errorMutableStateFlow.update { current ->
ErrorUiState(
dirty = current.dirty + 1,
message = null,
)
}
errorMutableStateFlow.update { current ->
ErrorUiState(
dirty = current.dirty + 1,
message = "Error",
)
}
}
}
}
data class ErrorUiState(
val dirty: Int,
val message: String?,
)Vilgot Fredenberg
04/13/2026, 1:09 PMPutting aWhat you are seeing is expected behaviour. Useis obviously not a viable option. Any alternative? Or fix incoming?delay(17)
MutableStateFlow when the latest state should be composed. Use (for example) a buffered Channel to notify the composition for each modification.Vilgot Fredenberg
04/13/2026, 1:13 PMErrorUiState referentially instead of structurally (though I very much dislike this)Nino
04/13/2026, 1:23 PMMutableStateFlow. In "pure Compose" the issue is still there...
var errorState: String? by remember {
mutableStateOf("Error")
}
LaunchedEffect(errorState) {
android.util.Log.d("Nino", "LaunchedEffect() called")
if (errorState != null) {
scope.launch {
val result = snackBarHostState.showSnackbar(
message = errorState!!,
actionLabel = "Retry",
)
when (result) {
SnackbarResult.Dismissed -> TODO()
SnackbarResult.ActionPerformed -> {
errorState = null
errorState = "Error"
}
}
}
}
}Vilgot Fredenberg
04/13/2026, 1:29 PMerrorState to null (notifying the snapshot system / composition that a change has taken place). And then...you immediately assign it back to its original value. Later when composition takes place, LaunchedEffect is skipped (because its arguments are equal). MutableStateFlow is "just" a CAS (compare-and-swap) wrapper so it behaves ~like a normal variable
Remember that everything is running in a single thread. Compose runs cooperatively alongside your own code (when your code is idle)Nino
04/13/2026, 1:33 PMVilgot Fredenberg
04/13/2026, 1:43 PMVilgot Fredenberg
04/13/2026, 1:43 PMMutableStateFlow is always the wrong tool if you need intermediary values. Use a ChannelVilgot Fredenberg
04/13/2026, 1:45 PMVilgot Fredenberg
04/13/2026, 1:47 PMRENDEZVOUS channel may also be interesting to you, since that allows you to "synchronize" with compose's renderingNino
04/13/2026, 2:00 PMLaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
viewModel.errorFlow.collectLatest { errorState ->
if (errorState != null) {
val result = snackBarHostState.showSnackbar(
message = errorState,
actionLabel = "Retry",
)
when (result) {
SnackbarResult.Dismissed -> TODO()
SnackbarResult.ActionPerformed -> viewModel.onRetry()
}
}
}
}
}
(By the way, I had to change to receiveAsFlow instead of consumeAsFlow because otherwise it would crash during a rotation)Vilgot Fredenberg
04/14/2026, 7:08 AMandroid:configChanges="allKnown"). In general you need to figure out what's supposed to be state vs what's supposed to be events. Then model events as Channel and state as State. I'm not sure how to model this, since I'm not sure what you are trying to accomplish.
The "issue" you ran into w.r.t. recreating your screen is that no new event was dispatched nor was any state retained.
Taking a guess at what you're trying to do is to re-display a snackbar, I would come up with a "refresh" event but keep the snackbar's content as state. Then let state changes or refresh events recreate the snackbar (from the current state).