Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour
Ali Mansour

Senior Software Engineer

Senior Mobile Engineer

Content Creator

Tech Speaker

Advanced ViewModel Patterns: One-Time Events, SavedState, and Retry Logic in Compose

October 21, 2025 Code
Advanced ViewModel Patterns: One-Time Events, SavedState, and Retry Logic in Compose

“My navigation event triggers again after rotation.”
“The last toast was shown twice when the user reopened the page.”
“My app loses state during a config change.”
If you’ve gone through these in Jetpack Compose (and who has not?), then these are the limitations of the fundamental ViewModel usage.

This article explores the advanced ViewModel patterns every experienced Android developer should know:
– One-time events (navigation, snackbars, etc.)
– Saved state restoration
– Retry with exponential backoff
– ViewModel testing best practices

Let’s break them down with code.

1. Understanding the “One-Time Event” Problem

In Compose, UI state is replayed after configuration changes or process recreation.

That’s perfect for persistent data like a loading state or form input,
but terrible for transient events — like navigation or a Snackbar message.

Imagine this:

val state by viewModel.state.collectAsState()
if (state.error != null) {
    Toast.makeText(context, state.error, Toast.LENGTH_SHORT).show()
}

Oops. That Toast will trigger again after every recomposition — even rotations.

We need event channels that:

  • Emit once
  • Are lifecycle-safe
  • Works nicely with Compose

2. Channels vs SharedFlow: Which to Use?

There are two main ways to handle one-time events:

PatternTypeBest ForBehavior
ChannelCold stream Simple one-shot eventsEach collector gets event once
SharedFlowHot stream Multi collectorsReplays configurable number of emissions

For most Compose screens, Channel is ideal: simple, one-off, and guaranteed delivery.
If you need multiple collectors (e.g., UI + logger), go with SharedFlow.

3. Implementing One-Time Events in ViewModel

Let’s create a NavViewModel that sends navigation commands once.

class NavViewModel : ViewModel() {

    private val _navChannel = Channel<String>(Channel.BUFFERED)
    val navigation = _navChannel.receiveAsFlow()

    fun onLoginSuccess() {
        viewModelScope.launch {
            _navChannel.send("home")
        }
    }
}

Then in your Composable:

@Composable
fun LoginScreen(vm: NavViewModel = viewModel(), onNavigate: (String) -> Unit) {
    val context = LocalContext.current

    LaunchedEffect(Unit) {
        vm.navigation.collect { route ->
            onNavigate(route)
        }
    }

    Button(onClick = { vm.onLoginSuccess() }) {
        Text("Login")
    }
}
  • Event sent once.
  • Survives recomposition.
  • Automatically collected only once by Compose.

4. SavedStateHandle — Surviving Process Death

Configuration changes are one thing.
But process death is another beast.

If your app is backgrounded and killed, your ViewModel dies too.
You can survive this using SavedStateHandle — part of the AndroidX lifecycle suite.

Example: Restoring a Post Detail ViewModel

@HiltViewModel
class PostDetailViewModel @Inject constructor(
    private val repo: PostRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {

    val postId: String = savedStateHandle["postId"] ?: ""

    private val _uiState = MutableStateFlow(PostDetailUi())
    val uiState = _uiState.asStateFlow()

    init {
        loadPost()
    }

    fun loadPost() = viewModelScope.launch {
        _uiState.update { it.copy(loading = true) }
        val post = repo.getPost(postId)
        _uiState.update { it.copy(loading = false, post = post) }
    }
}

Passing arguments through navigation:

navController.navigate("postDetail/${post.id}")

And in your NavGraph:

composable("postDetail/{postId}") { backStack ->
    val postId = backStack.arguments?.getString("postId") ?: ""
    val vm: PostDetailViewModel = hiltViewModel()
    PostDetailScreen(vm)
}
  • The post ID is automatically restored even after process death.
  • No manual persistence or SharedPreferences needed.

5. Retry Logic — Done Elegantly

Every network call fails sometimes.
Instead of spamming users with “Retry” buttons or writing ugly loops,
You can use structured concurrency + exponential backoff.

Here’s a neat extension function:

suspend fun <T> retryWithBackoff(
    retries: Int = 3,
    initialDelay: Long = 300,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(retries - 1) {
        runCatching { return block() }
        delay(currentDelay)
        currentDelay = (currentDelay * factor).toLong()
    }
    return block()
}

Use it in your ViewModel:

class WeatherViewModel(private val repo: WeatherRepo) : ViewModel() {
    private val _ui = MutableStateFlow(WeatherUi())
    val ui = _ui.asStateFlow()

    fun refreshWeather() = viewModelScope.launch {
        _ui.update { it.copy(loading = true) }
        runCatching {
            retryWithBackoff {
                repo.getWeather()
            }
        }.onSuccess {
            _ui.update { it.copy(loading = false, data = it) }
        }.onFailure { e ->
            _ui.update { it.copy(loading = false, error = e.message) }
        }
    }
}
  • Simple.
  • Configurable.
  • Works with coroutines and test dispatchers.

6. Testing ViewModels the Right Way

A good ViewModel test checks:

  • State transitions
  • One-time events
  • Retry logic (using fake repos or mock delays)

Here’s an example:

@OptIn(ExperimentalCoroutinesApi::class)
class WeatherViewModelTest {

    private val fakeRepo = object : WeatherRepo {
        var calls = 0
        override suspend fun getWeather(): String {
            calls++
            if (calls < 3) error("Network down")
            return "Sunny"
        }
    }

    @Test
    fun `retries twice then succeeds`() = runTest {
        val vm = WeatherViewModel(fakeRepo)
        vm.refreshWeather()
        advanceUntilIdle()
        assertEquals("Sunny", vm.ui.value.data)
        assertEquals(3, fakeRepo.calls)
    }
}
  • No Android dependencies.
  • Deterministic behavior via advanceUntilIdle().
  • Fully testable retry logic.

7. Compose Integration Tips

TaskBest Practice
NavigationEmit routes via Channel, collect in `LaunchedEffect`
Snackbars/ToastsUse SharedFlow or Channel
Saved state`SavedStateHandle` for simple args, DataStore for persistent data
Long-running jobsUse `viewModelScope.launch(Dispatchers.IO)`
TestingUse `runTest` + fake repos

Compose simplifies state management — but you need discipline to separate stateevents, and effects properly.

Real-World Example: Login with Retry + Events + SavedState

Let’s combine everything we learned.

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val repo: AuthRepository,
    private val saved: SavedStateHandle
) : ViewModel() {

    private val _ui = MutableStateFlow(LoginUi())
    val ui = _ui.asStateFlow()

    private val _events = Channel<LoginEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()

    fun login(email: String, pass: String) = viewModelScope.launch {
        _ui.update { it.copy(loading = true) }

        runCatching {
            retryWithBackoff { repo.login(email, pass) }
        }.onSuccess {
            _ui.update { it.copy(loading = false) }
            _events.send(LoginEvent.NavigateHome)
        }.onFailure {
            _ui.update { it.copy(loading = false, error = it.message) }
            _events.send(LoginEvent.ShowToast("Login failed"))
        }
    }
}

data class LoginUi(val loading: Boolean = false, val error: String? = null)
sealed interface LoginEvent {
    data object NavigateHome : LoginEvent
    data class ShowToast(val msg: String) : LoginEvent
}

And in Compose:

@Composable
fun LoginScreen(vm: LoginViewModel = hiltViewModel(), onNavigateHome: () -> Unit) {
    val state by vm.ui.collectAsStateWithLifecycle()
    val ctx = LocalContext.current

    LaunchedEffect(Unit) {
        vm.events.collect { event ->
            when (event) {
                is LoginEvent.ShowToast -> Toast.makeText(ctx, event.msg, Toast.LENGTH_SHORT).show()
                LoginEvent.NavigateHome -> onNavigateHome()
            }
        }
    }

    // Compose UI…
}
  • Retry logic handled cleanly.
  • State restored automatically.
  • Navigation + toasts are emitted once only.

8. Why These Patterns Matter

As your app grows:

  • One-time events prevent duplicated triggers.
  • SavedStateHandle ensures resilient user flows.
  • Retry strategies avoid user frustration.
  • Testing makes refactoring safe.

These are the patterns senior engineers rely on to keep codebases sane — and what Google looks for in GDEs and speaker-level engineers.

Wrapping Up

ViewModels are powerful — but only if you use them beyond the basics.

With Channels, SavedStateHandle, and retry backoff, your Compose architecture becomes production-grade:

  • Predictable events
  • Resilient state
  • Testable concurrency

Compose makes it beautiful. You make it bulletproof.

TL;DR

  • Use Channel or SharedFlow for one-time events.
  • Persist lightweight UI data with SavedStateHandle.
  • Use retryWithBackoff() for reliable network resilience.
  • Test everything with runTest , and fake repositories.

Author’s Note

If this article helped you level up your Android craft, follow me for the next chapter in “Mastering Modern Android Development.”

Your code deserves clarity, not chaos. Let’s build it right.

Tags:
Write a comment

Verified by MonsterInsights