
Global reactive cache with TTL, queries/mutations/flows and infinite pagination, transactional optimistic updates with rollback, stale-while-revalidate, per-key locking, factory-based APIs, type-safe errors and refetch.
A set of solutions for operating on a global cache. Heavily inspired by:
Platforms: JVM, Android, iOS (x64/arm64/simulator), WASM-JS
CacheOnHand provides the following:
refetch() and optimisticUpdater() functions that exist at definition (not at use), and are usable anywhereCache On Hand is split into three modules that build on each other. Use only what you need:
| Module | Description | Use when... |
|---|---|---|
cacheonhand |
Thread-safe reactive cache with TTL | You just need a reactive in-memory cache |
cacheonhand-attendants |
Query, mutation, flow, and infinite query operations | You want managed data fetching with state tracking |
cacheonhand-compose |
Compose Multiplatform wrappers | You're building Compose UI |
Each module transitively includes its dependencies — adding cacheonhand-compose gives you everything.
// build.gradle.kts
dependencies {
// Pick one — each includes its dependencies
implementation("io.github.notoriouscorgi:cacheonhand:<version>") // cache only
implementation("io.github.notoriouscorgi:cacheonhand-attendants:<version>") // cache + operations
implementation("io.github.notoriouscorgi:cacheonhand-compose:<version>") // cache + operations + compose
}// Define a cache key
data class GetUserInput(val userId: String) : CacheableInput.QueryInput {
override val identifier = "GET /api/users/$userId"
}
// Create a shared cache
val cache = OnHandCache()
// Define a query factory
val getUserFactory = queryFactoryOf<GetUserInput, User, ApiException>(
cache = cache,
) {
withContext(Dispatchers.IO) { input ->
api.getUser(input.userId)
}
}
// Query your data
val queryAndResult = getUserFactory()
// Start listening to results
queryAndResult.result.collect { ... }
// Fetch your query
viewModelScope.launch {
queryAndResult.fetch(GetUserInput(1))
}
// Wrap for Compose
val rememberGetUser = composeQueryFactoryOf(getUserFactory)
// Use in a composable
@Composable
fun UserScreen(userId: String) {
val result = rememberGetUser(input = GetUserInput(userId))
when (result.fetchState) {
FetchState.LOADING -> CircularProgressIndicator()
FetchState.SUCCESS -> Text("Hello, ${result.data?.name}")
FetchState.ERROR -> Text("Error: ${result.error?.message}")
FetchState.IDLE -> {}
}
}Refresh query data after a mutation by calling refetch in onSuccess:
val mutation = updateUserFactory.create()
mutation.mutate(
queryInput = UpdateUserInput("123", "New Name"),
onSuccess = { _ ->
getUserFactory.refetch(GetUserInput("123"))
// All active query instances observing this key update automatically
},
)
// Use launch() for fire-and-forget refetches (e.g., refreshing data not currently on screen)
mutation.mutate(
queryInput = UpdateUserInput("123", "New Name"),
onSuccess = { _ ->
scope.launch { getUserFactory.refetch(GetUserInput("123")) }
},
)TError parameter instead of raw Throwable
These assumptions apply across all modules:
equals/
hashCode will silently create duplicate entries. Always use data class for your CacheableInput implementations.Dispatchers.Default — use withContext(Dispatchers.IO) inside your query/mutation/flow lambda
for network or disk calls.launch/async without awaiting inside factory lambdas — the operation must complete sequentially so
state transitions (LOADING -> SUCCESS/ERROR) are correct. However, launch/async is fine inside onSuccess/
onError callbacks — those run after state transitions are complete.refetch() throws on failure — unlike fetch() which catches errors and sets ERROR state, refetch() propagates
exceptions to the caller. Wrap in try-catch or scope.launch../gradlew build# All modules
./gradlew cacheonhand:jvmTest cacheonhand-attendants:jvmTest cacheonhand-compose:jvmTest
# Single module
./gradlew cacheonhand:jvmTest
./gradlew cacheonhand-attendants:jvmTest
./gradlew cacheonhand-compose:jvmTestCreate a release and publish to Maven Central:
./scripts/release.sh # auto-increments minor version (0.1.0 → 0.2.0)
./scripts/release.sh 1.0.0 # explicit versionOr trigger directly from GitHub Actions (Actions -> Publish -> Run workflow) with an optional version input.
The workflow runs tests first, then publishes all three modules to Maven Central.
Generates API documentation from source and READMEs into docs/ for GitHub Pages:
./gradlew :dokkaGenerateLicensed under the Apache License, Version 2.0
A set of solutions for operating on a global cache. Heavily inspired by:
Platforms: JVM, Android, iOS (x64/arm64/simulator), WASM-JS
CacheOnHand provides the following:
refetch() and optimisticUpdater() functions that exist at definition (not at use), and are usable anywhereCache On Hand is split into three modules that build on each other. Use only what you need:
| Module | Description | Use when... |
|---|---|---|
cacheonhand |
Thread-safe reactive cache with TTL | You just need a reactive in-memory cache |
cacheonhand-attendants |
Query, mutation, flow, and infinite query operations | You want managed data fetching with state tracking |
cacheonhand-compose |
Compose Multiplatform wrappers | You're building Compose UI |
Each module transitively includes its dependencies — adding cacheonhand-compose gives you everything.
// build.gradle.kts
dependencies {
// Pick one — each includes its dependencies
implementation("io.github.notoriouscorgi:cacheonhand:<version>") // cache only
implementation("io.github.notoriouscorgi:cacheonhand-attendants:<version>") // cache + operations
implementation("io.github.notoriouscorgi:cacheonhand-compose:<version>") // cache + operations + compose
}// Define a cache key
data class GetUserInput(val userId: String) : CacheableInput.QueryInput {
override val identifier = "GET /api/users/$userId"
}
// Create a shared cache
val cache = OnHandCache()
// Define a query factory
val getUserFactory = queryFactoryOf<GetUserInput, User, ApiException>(
cache = cache,
) {
withContext(Dispatchers.IO) { input ->
api.getUser(input.userId)
}
}
// Query your data
val queryAndResult = getUserFactory()
// Start listening to results
queryAndResult.result.collect { ... }
// Fetch your query
viewModelScope.launch {
queryAndResult.fetch(GetUserInput(1))
}
// Wrap for Compose
val rememberGetUser = composeQueryFactoryOf(getUserFactory)
// Use in a composable
@Composable
fun UserScreen(userId: String) {
val result = rememberGetUser(input = GetUserInput(userId))
when (result.fetchState) {
FetchState.LOADING -> CircularProgressIndicator()
FetchState.SUCCESS -> Text("Hello, ${result.data?.name}")
FetchState.ERROR -> Text("Error: ${result.error?.message}")
FetchState.IDLE -> {}
}
}Refresh query data after a mutation by calling refetch in onSuccess:
val mutation = updateUserFactory.create()
mutation.mutate(
queryInput = UpdateUserInput("123", "New Name"),
onSuccess = { _ ->
getUserFactory.refetch(GetUserInput("123"))
// All active query instances observing this key update automatically
},
)
// Use launch() for fire-and-forget refetches (e.g., refreshing data not currently on screen)
mutation.mutate(
queryInput = UpdateUserInput("123", "New Name"),
onSuccess = { _ ->
scope.launch { getUserFactory.refetch(GetUserInput("123")) }
},
)TError parameter instead of raw Throwable
These assumptions apply across all modules:
equals/
hashCode will silently create duplicate entries. Always use data class for your CacheableInput implementations.Dispatchers.Default — use withContext(Dispatchers.IO) inside your query/mutation/flow lambda
for network or disk calls.launch/async without awaiting inside factory lambdas — the operation must complete sequentially so
state transitions (LOADING -> SUCCESS/ERROR) are correct. However, launch/async is fine inside onSuccess/
onError callbacks — those run after state transitions are complete.refetch() throws on failure — unlike fetch() which catches errors and sets ERROR state, refetch() propagates
exceptions to the caller. Wrap in try-catch or scope.launch../gradlew build# All modules
./gradlew cacheonhand:jvmTest cacheonhand-attendants:jvmTest cacheonhand-compose:jvmTest
# Single module
./gradlew cacheonhand:jvmTest
./gradlew cacheonhand-attendants:jvmTest
./gradlew cacheonhand-compose:jvmTestCreate a release and publish to Maven Central:
./scripts/release.sh # auto-increments minor version (0.1.0 → 0.2.0)
./scripts/release.sh 1.0.0 # explicit versionOr trigger directly from GitHub Actions (Actions -> Publish -> Run workflow) with an optional version input.
The workflow runs tests first, then publishes all three modules to Maven Central.
Generates API documentation from source and READMEs into docs/ for GitHub Pages:
./gradlew :dokkaGenerateLicensed under the Apache License, Version 2.0