Compose 1.10 introduced the new retain API that has a different lifecycle than remember and rememberSaveable. I recommend reading these excellent series of blog posts on it:
- Previewing retain{} API: A New Way to Persist State in Jetpack Compose
- Previewing RetainedEffect: A New Side Effect to Bridge Between Composition and Retention Lifecycles
- Understanding retain{} internals: A Scope-based State Preservation in Jetpack Compose
A retained value can survive a configuration change without being serialized. This is exactly what Jetpack ViewModel does!
This got me thinking: what if we removed the Android lifecycle handling from ViewModels entirely? What if they were just plain Kotlin classes for state management? Turns out this is not only possible, it simplifies things considerably!
The ViewModel Problem
Let’s take the example of a simple ViewModel + Screen setup in Compose with Nav3 + Hilt:
1@HiltViewModel
2@Inject
3class AuthViewModel(...): ViewModel() {
4 val state: StateFlow<UiState>
5 fun login(creds: Credentials) { .. }
6 fun logout() { .. }
7}
8
9@Composable
10fun AuthScreen(viewModel: AuthViewModel) {
11 ..
12}
13
14interface AuthScreenProviders {
15 @IntoSet
16 @Provides
17 fun provideAuthEntryProviderScope(): RouteEntryProviderScope = {
18 entry<Route.Auth> { AuthScreen(viewModel = hiltViewModel()) }
19 }
20}
This should look familiar. What stands out is the special treatment required to create a ViewModel instance. Most DI frameworks nowadays ship a separate ViewModel artifact to allow a ViewModel to be injectable. The @HiltViewModel annotation writes a bunch of binding code, and hiltViewModel() does a lot of heavy lifting and abstracts away the creation logic with the ViewModelProvider.Factory.
A Simpler Approach
With retain, the configuration change survival becomes a UI concern and we can make our presenters plain Kotlin classes and injectable just like any other dependency. No more special treatment!
Here’s what that looks like:
1@Inject
2class AuthPresenter(...) {
3 val state: StateFlow<UiState>
4 fun login(creds: Credentials) { .. }
5 fun logout() { .. }
6}
7
8interface AuthScreenProviders {
9 @IntoSet
10 @Provides
11 fun provideRoute(presenter: Provider<AuthPresenter>): RouteEntryProviderScope = {
12 entry<Route.Auth> { AuthScreen(presenter = retain { presenter() }) }
13 }
14}
Handling Cleanup
You might now ask: How do we clean up resources like coroutine scopes when the retained value is retired? The RetainObserver interface gives us lifecycle hooks for retained objects and we can use onRetired() to handle cleanup when the object is no longer needed.
Here’s how that would look:
1interface Presenter {
2 fun close()
3}
4
5/** Retains a presenter and closes it when retired */
6@Composable
7inline fun <reified P : Presenter> retainPresenter(
8 noinline calculation: () -> P
9): P {
10 return retain { RetainedPresenterObserver(calculation()) }.value
11}
12
13class RetainedPresenterObserver<P : Presenter>(val value: P) : RetainObserver {
14 override fun onRetained() = Unit
15 override fun onEnteredComposition() = Unit
16 override fun onExitedComposition() = Unit
17 override fun onUnused() = Unit
18 override fun onRetired() {
19 value.close()
20 }
21}
And now you can use it like this:
1fun provideRoute(presenter: Provider<AuthPresenter>): RouteEntryProviderScope = {
2 entry<Route.Auth> {
3 AuthScreen(presenter = retainPresenter { presenter() })
4 }
5}
Navigation 3 Support
Similar to how ViewModel requires ViewModelStoreNavEntryDecorator, using retain requires a RetainedValuesStoreNavEntryDecorator which is currently under development as of this writing. It’ll likely ship as a runtime-retain-navigation3 artifact soon.
This decorator provides a RetainedValuesStore for each backstack entry, allowing retain to respect navigation state. Retained values survive while the entry is in the backstack and get retired when the entry is removed.
Wrapping Up
The retain API shifts configuration survival from a framework concern (ViewModel) to a UI concern. Our presenters become simpler. Just regular Kotlin classes that the UI layer chooses to retain. No need to extend ViewModel, no special DI setup.
I’ve been experimenting with this approach in my playground project if you want to see it in practice.
ViewModel still has its place, but for most cases, retain with simple presenters should get us the same benefits with less ceremony.