Library / SDK
sebaslogen/resaca avatar
sebaslogen/resaca

resaca: scoping a ViewModel to a Composable instead of a whole screen

Compose Multiplatform library to scope ViewModels to a Composable, surviving configuration changes and navigation

651 stars16 forksKotlinMIT

At a glance

What is it?
Resaca is a small Kotlin library for Compose Multiplatform that keeps an object or a ViewModel alive for the lifetime of a @Composable function, across recompositions, configuration changes and navigation backstack entries. It survives with two composable functions, rememberScoped and viewModelScoped, and ships separate modules for Hilt, Koin and Metro.
Who is it for?
resaca suits Compose developers building fine grained, reusable components who want each piece of UI to own its own ViewModel instead of sharing one screen sized class, and it suits multiplatform teams that want the same scoping on Android, iOS, desktop JVM and web.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 3 days ago.
What is it written in?
Mainly Kotlin, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The ViewModel is scoped to a screen, the Composable is not

Resaca is described as providing the right scope for objects and ViewModels in Android Compose, and the project description frames it as a Compose Multiplatform library that scopes ViewModels to a Composable so they survive configuration changes and navigation.

The problem it names in the Why section is a mismatch of granularities. Compose allows the creation of fine-grained UI components that can be reused like building blocks, and a well architected Android app isolates functionality in small business logic components such as use cases, interactors and repositories that are reusable in the same way. But the standard tool for connecting UI to business logic, a Jetpack ViewModel, can only be scoped to a whole screen or a larger scope, never to a smaller Compose component inside that screen.

The consequence, as the README puts it, is that UI blocks are glued to business logic blocks with a big glue class for the whole screen.

Resaca's answer is to keep a Jetpack ViewModel, or any other object, in memory during the lifecycle of a @Composable function and clean it up automatically when it is no longer needed. The README is specific about what that survives: recomposition, configuration changes, and the container Fragment or Compose Navigation destination going into the backstack.

That last one is the interesting claim. A ViewModel that is scoped to a navigation destination stays alive when the destination is on the backstack, so state survives the round trip. A ViewModel scoped to a Composable can be narrower still, and the cleanup is automatic rather than something you have to remember.

The library is MIT licensed, published on Maven Central as io.github.sebaslogen:resaca, and the README describes it as less than 5Kb.

Two functions do the work

The API surface is deliberately tiny. Inside a @Composable function you create and retrieve an object with rememberScoped, which remembers any type of object except ViewModels, and you use viewModelScoped for ViewModels.

Scoping a plain object looks like this:

kotlin
@Composable
fun DemoScopedObject() {
    val myRepository: MyRepository = rememberScoped { MyRepository() }
    DemoComposable(inputObject = myRepository)
}

And the ViewModel version:

kotlin
@Composable
fun DemoScopedViewModel() {
    val myScopedVM: MyViewModel = viewModelScoped()
    DemoComposable(inputObject = myScopedVM)
}

The important thing is what the return types are. In the first case the type is inferred from the lambda, so the object is created once and reused for the lifetime of the composable scope. In the second case the type comes from the assignment, so viewModelScoped has to know which ViewModel class you want, and the parameterless call is the plain case.

A third form takes a factory, which is what you use when the ViewModel needs constructor arguments:

kotlin
@Composable
fun DemoScopedViewModelWithDependency() {
    val myScopedVM: MyViewModelWithDependencies = viewModelScoped { MyViewModelWithDependencies(myDependency) }
    DemoComposable(inputObject = myScopedVM)
}

For a reader coming from screen scoped ViewModels, the change in mental model is the point. Instead of one ViewModel per screen that coordinates everything, you get one small ViewModel per component, created where the component is, and disposed when the component leaves composition.

Keys, factory arguments and a clear delay for quick navigation

Three parameters decide the lifetime in practice.

The first is a key. Passing a string key gives you a distinct instance for the same ViewModel type inside the same composable scope, which the README demonstrates with two MyViewModel instances carrying different data.

kotlin
@Composable
fun DemoViewModelWithKey() {
    val scopedVMWithFirstKey: MyViewModel = viewModelScoped("myFirstKey") { MyViewModel("myFirstKey") }
    val scopedVMWithSecondKey: MyViewModel = viewModelScoped("mySecondKey") { MyViewModel("mySecondKey") }
    DemoComposable(inputObject = scopedVMWithFirstKey)
    DemoComposable(inputObject = scopedVMWithSecondKey)
}

The second is the clear delay, which is the most interesting design choice in the library. The ViewModel is kept in memory for a period after the Composable is disposed, giving it a chance to be reused if the Composable returns to composition, for example on a quick navigation back and forth.

kotlin
@Composable
fun DemoScopedViewModelWithClearDelay() {
    val myScopedVM: MyViewModel = viewModelScoped(clearDelay = 5.seconds)
    DemoComposable(inputObject = myScopedVM)
}

The same parameter exists on rememberScoped, so plain objects get the same grace period.

The third is the LazyColumn case, which the README lists as its own example: use a different ViewModel for each item and scope them to the Composable that contains the LazyColumn, rather than to each item. That is the pattern that turns a list screen from one large ViewModel into a container plus small per item ones, and it is exactly the scenario the library was built for.

Hilt, Koin and Metro live in their own modules

Dependency injection support is where the repository layout gets interesting. The root holds resaca/, resacahilt/, resacakoin/ and resacametro/ as separate modules, alongside sample/ and samplecmp/.

The separation is a packaging decision with a real effect: an application that uses Hilt does not pull in the Koin or Metro integration, and a project that does not use any of them takes only the core artifact.

The Koin example constructs the ViewModel through the framework rather than by hand:

kotlin
@Composable
fun DemoKoinInjectedViewModelWithDependency() {
    val myInjectedScopedVM: MyViewModelWithDependencies = viewModelScoped() { getKoin().get { parametersOf(myConstructorDependency) } }
    DemoComposable(inputObject = myInjectedScopedVM)
}

And Metro, which is the more recent of the three dependency injection options, uses a dedicated composable with a factory argument:

kotlin
@Composable
fun DemoMetroInjectedViewModelScoped() {
    val myInjectedScopedVM: MyViewModel = metroViewModelScoped(factory = myMetroViewModelFactory)
    DemoComposable(inputObject = myInjectedScopedVM)
}

That metroViewModelScoped is a separate function rather than an overload is a small signal about how the library integrates with different frameworks. Hilt gets its own module, Koin gets its own module, Metro gets its own module, and each exposes the scoping behaviour in the idiom that framework's users already recognise.

The two sample directories, sample/ and samplecmp/, suggest the same split for a regular app and for a Compose Multiplatform app, with sample/ carrying its own README, a lint baseline, ProGuard rules and a build file.

One library across Android, iOS, desktop and web

The platform story is what separates this from an ordinary Android library. The supported platforms badge lists Android, iOS, Desktop JVM and Web, and the topics tag the project as a compose-multiplatform-library as well as an android-library.

The reason that is possible is the scope mechanism itself. A ViewModel scoped to a Composable does not need a ViewModelStoreOwner from the framework to give it an owner, because the owner is the composable scope itself. That removes the dependency on an activity or a fragment, and with it the platform specific part of the problem.

The floor is API level 21 on Android, according to the badge in the README. The repository also contains kotlin-js-store/ for the JavaScript dependencies, which is what a Kotlin multiplatform library needs to pin the npm side of the build, and the build files are build.gradle.kts, settings.gradle.kts, gradle.properties and the gradle wrapper with gradlew and gradlew.bat.

The installation itself is a single dependency, and the README notes that the latest version is shown in the Maven Central badge, with X.X.X standing in for it:

kotlin
// In module's build.gradle.kts
dependencies {
    implementation("io.github.sebaslogen:resaca:X.X.X")
}

For a library whose whole purpose is to be invisible in your code, that is about as small an adoption step as you can ask for.

The article behind it, the badges, and the release line

Resaca has a piece of writing that explains the idea, and the README links it at the top: an article titled Every Composable deserves a ViewModel, published on the Q42 engineering blog at engineering.q42.nl/compose. That is worth reading before you read the code, because the argument in it is the argument for splitting a screen ViewModel into component sized ones.

The badge row tells you how the project is run. There is a build status badge for the build.yml GitHub Actions workflow, a codecov badge for coverage, a javadoc badge pointing at the published API documentation, an API 21 plus badge, a supported platforms badge, and badges for the Maven Central release version. It has also been picked up by Kotlin Weekly and Android Weekly, which is a measure of visibility rather than of quality, but it does tell you the library reached the wider Android community.

The repository also carries the usual files for a project run by a team, with SECURITY.md, CODE_OF_CONDUCT.md, an AGENTS.md file and directories for .claude/ and .agents/.

On releases, the cadence is fast. 5.1.5 was published on 2026-09-02, 5.1.6 on 2026-09-17 and 5.1.7 on 2026-10-03, and the last push to the repository was on 2026-10-20. That is three patch releases in five weeks, which suits a small library but means you should pin a version rather than float on the latest, and it means the module list, which now has four artifacts for the core and the three dependency injection integrations, is something to check when you upgrade.

Editorial conclusion

resaca suits Compose developers building fine grained, reusable components who want each piece of UI to own its own ViewModel instead of sharing one screen sized class, and it suits multiplatform teams that want the same scoping on Android, iOS, desktop JVM and web. It does not suit a project that has no Compose code, and it does not suit anyone who expects a component scoped state to be a drop-in replacement for a screen scoped ViewModel, because the two have different lifetimes and different owners. Check three things before you adopt it: that the API level floor of 21 and the platforms list match your targets, that you are ready to split a screen ViewModel into several small ones, since that is a refactor rather than an addition, and that you read the release notes, because the library ships patch versions every few weeks and the module set has grown over time. The last push to the repository was on 2026-10-20 and the newest release is 5.1.7 from 2026-10-03.

Frequently asked questions

What does Resaca do?

It keeps a Jetpack ViewModel, or any other object, in memory for the lifetime of a @Composable function and cleans it up automatically when it is no longer needed, so the instance survives recompositions, configuration changes and a container Fragment or Compose Navigation destination going into the backstack.

How do I add Resaca to an Android project?

Add the dependency to your module's build.gradle.kts, with the version taken from the Maven Central badge in the README, using implementation("io.github.sebaslogen:resaca:X.X.X"). The library is under 5Kb and is MIT licensed.

What is the difference between rememberScoped and viewModelScoped?

rememberScoped remembers any type of object except ViewModels, creating it from a lambda you provide. viewModelScoped is for ViewModels and takes the type from the assignment, with an optional factory lambda for constructor arguments.

Does Resaca work with Hilt, Koin and Metro?

Yes, through separate modules, resacahilt, resacakoin and resacametro, so you only take the integration you use. The Metro path uses the metroViewModelScoped composable with a factory argument, and the Koin path resolves the ViewModel through getKoin().get inside the factory.

Which platforms does Resaca support?

The supported platforms badge lists Android, iOS, Desktop JVM and Web, and the Android floor is API level 21. The project is published on Maven Central as io.github.sebaslogen:resaca.

Official sources

  1. Issues
  2. License: MIT
  3. README
  4. Releases
  5. sebaslogen/resaca on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/sebaslogen-resaca.svg)](https://hysenlabs.com/projects/sebaslogen-resaca)

Was this useful?