Skip to content

Container

Maven Central API License: Apache 2 PR Check Coverage Publish

Container is a library for simplifying state management and data loading in Android applications. It provides a small set of building blocks that cover the most common reactive patterns: wrapping async results in a typed status, managing derived state from multiple flows, and lazily loading data on demand.

Table of Contents

Installation

Add the following line to your build.gradle file:

implementation "com.elveum:container:2.1.0-beta03"

Core Concepts

The library is built around three building blocks:

  • Container<T> - a sealed type that represents the state of an async operation as Pending, Success<T>, or Error
  • Reducer<State> - converts one or more Kotlin Flows into a StateFlow<State>, with support for manual state updates
  • LazyFlowSubject<T> - wraps a loader function in a lazily-started Flow<Container<T>> with built-in caching, reloading, and Container status handling

Container Type

Container<T> is a sealed class that represents the current status of an asynchronous load or operation. It has three possible states:

  • Container.Pending - the operation is still in progress
  • Container.Success<T> - the operation completed successfully and holds a value: T
  • Container.Error - the operation failed and holds an exception: Exception

Use the Kotlin when keyword or the fold call to handle all three states in one place:

val container: Container<String> = successContainer("Hello")

container.fold(
    onPending = { /* show a progress spinner */ },
    onError = { exception -> /* show an error message */ },
    onSuccess = { value -> /* render the data */ },
)

Containers can be created with factory functions:

val pending = pendingContainer()
val success = successContainer("data")
val error   = errorContainer(IOException("network error"))

For a complete guide (including value extraction, transformations, combining flows, and more), see Container Type.

Reducer Pattern

Reducer<State> converts any Kotlin Flow into a StateFlow<State> while also allowing manual state updates. This makes it easy to drive a screen's UI state from one or more reactive sources, with the ability to apply local changes on top.

@HiltViewModel
class MyViewModel @Inject constructor(
    private val getItems: GetItemsUseCase,
) : ViewModel() {

    data class State(
        val items: List<String> = emptyList(),
        val filter: String = "",
    )

    private val reducer = getItems() // Flow<List<String>>
        .toReducer(
            initialState = State(),
            nextState = State::copy,
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
        )

    val stateFlow: StateFlow<State> = reducer.stateFlow

    fun applyFilter(filter: String) {
        reducer.update { it.copy(filter = filter) }
    }
}

ContainerReducer<State> is the container-aware variant. It exposes a StateFlow<Container<State>> so the UI automatically sees Pending, Error, and Success states without any manual bookkeeping:

private val reducer: ContainerReducer<State> = getItems()
    .toContainerReducer(
        initialState = ::State,
        nextState = State::copy,
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
    )

val stateFlow: StateFlow<Container<State>> = reducer.stateFlow

For the full API (combining multiple flows, the ReducerOwner interface, and the public-interface / private-implementation state pattern), see Reducer Pattern.

LazyFlowSubject

LazyFlowSubject<T> converts a loader function into a Flow<Container<T>>. The loader runs lazily (only when at least one subscriber is active) and its latest result is cached so that new subscribers do not re-trigger loading:

LazyFlowSubject is more powerful and leads to simpler code than the built-in stateIn / shareIn operators: it does not require a CoroutineScope, automatically wraps results in Container<T> to handle loading and error states, supports reloading out of the box, and is compatible with any caching strategy.

class ProductRepository(
    private val localDataSource: ProductsLocalDataSource,
    private val remoteDataSource: ProductsRemoteDataSource,
) {

    private val productsSubject = LazyFlowSubject.create {
        val local = localDataSource.getProducts()
        if (local != null) emit(local)
        val remote = remoteDataSource.getProducts()
        localDataSource.save(remote)
        emit(remote)
    }

    // ListContainerFlow<T> is an alias for Flow<Container<List<T>>>
    fun listenProducts(): ListContainerFlow<Product> = productsSubject.listen()

    fun reload() = productsSubject.reloadAsync()
}

Key behaviours:

  • Instead of listen(), you can use listenReloadable() call, which attaches a reload function to every emitted container, enabling pull-to-refresh patterns out of the box (no need to write a separate reload() function).
  • The loader is cancelled when the last subscriber stops collecting (after a configurable timeout, default 1 s).
  • After the timeout the cached value is cleared, so the next subscriber triggers a fresh load
  • You can replace the loader at any time with newLoad / newSimpleLoad
  • You can push a value directly with updateWith

For advanced usage (load triggers, source types, flow dependencies, and SubjectFactory for testability) see Subjects & Cache.

Pagination

pageLoader turns any key-based data source into a ValueLoader that can be passed directly to LazyFlowSubject.create. Pages are fetched on demand as the user scrolls, and the results are concatenated automatically into a single List<T>:

private val subject = LazyFlowSubject.create(
    valueLoader = pageLoader<Int, Order>(
        initialKey = 0,
        itemId = Order::id,
    ) { pageKey ->
        val page = ordersDataSource.fetchPage(pageKey)
        emitPage(page.orders)
        if (page.nextKey != null) emitNextKey(page.nextKey)
    }
)

fun listenOrders(): Flow<Container<List<Order>>> = subject.listenReloadable()

In the UI, call metadata.onItemRendered(index) inside your LazyColumn to trigger next-page loads as the user scrolls, and read metadata.nextPageState to show a footer spinner or retry button:

container.fold(
    onPending = { CircularProgressIndicator() },
    onError = { exception -> /* full-screen error */ },
    onSuccess = { orders ->
        LazyColumn {
            itemsIndexed(orders, key = { _, o -> o.id }) { index, order ->
                LaunchedEffect(index) { metadata.onItemRendered(index) }
                OrderItem(order)
            }
            item {
                when (val state = metadata.nextPageState) {
                    PageState.Pending -> CircularProgressIndicator()
                    is PageState.Error -> Button(onClick = { state.retry() }) { Text("Retry") }
                    else -> {}
                }
            }
        }
    },
)

For the full guide (pull-to-refresh, error handling, flow dependencies, and per-item updates), see Pagination.

Detailed Documentation

Topic Description
Container Type States, value extraction, transformations, flow extensions, combining flows
Reducer Pattern Reducer, ContainerReducer, combining flows, ReducerOwner
Subjects LazyFlowSubject, metadata, source types
Pagination PageLoader, next-page states, pull-to-refresh, flow dependencies, per-item updates