Reducer Pattern
Reducers make state management easy by letting you convert one or more Kotlin
Flows into a single StateFlow while keeping the ability to apply manual
updates on top. This page covers everything from basic usage to advanced
patterns.
Table of Contents
- Reducer vs ContainerReducer
- Creating a Reducer
- Creating a ContainerReducer
- Combining Multiple Flows
- Manual State Updates
- ReducerOwner Interface
- Public Interface / Private Implementation Pattern
- API Reference
Reducer vs ContainerReducer
The library provides two reducer types:
| Type | stateFlow type |
Use when |
|---|---|---|
Reducer<State> |
StateFlow<State> |
The UI only needs to see a plain value, not loading/error status |
ContainerReducer<State> |
StateFlow<Container<State>> |
The UI needs to handle Pending, Error, and Success states |
Both types expose a stateFlow property and an update method. In addition,
ContainerReducer has an updateState method that modifies the unwrapped
value inside a Container.Success (and is a no-op for other states).
Creating a Reducer
From a Plain Flow
Convert any Flow<T> to a Reducer<State> by specifying an initial state
and a nextState function that merges the latest flow value into your state:
data class State(
val items: List<String> = emptyList(),
val filterEnabled: Boolean = false,
)
private val reducer: Reducer<State> = getItemsFlow() // Flow<List<String>>
.toReducer(
initialState = State(),
nextState = State::copy, // (state, items) -> state.copy(items = items)
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
)
val stateFlow: StateFlow<State> = reducer.stateFlow
The nextState parameter is a function of type (State, T) -> State, which
data class's generated copy(items = ...) satisfies automatically as a
method reference when the field names match.
If your state class is the same type as the flow value (no wrapping needed),
you can omit nextState:
private val reducer: Reducer<List<String>> = getItemsFlow()
.toReducer(
initialState = emptyList(),
scope = viewModelScope,
started = SharingStarted.Lazily,
)
From a Flow of Containers
Use toReducer and unwrap the input container inside nextState if you want
to convert Flow<Container<T>> into a plain StateFlow<State>:
data class State(
val user: User = User.Anonymous,
)
private val reducer: Reducer<State> = usersRepository.getCurrentUser() // Flow<Container<User>>
.toReducer(
initialState = State(),
nextState = { oldState, container ->
oldState.copy(user = container.getOrNull() ?: User.Anonymous)
},
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
)
Creating a ContainerReducer
From a Plain Flow
toContainerReducer wraps the flow values inside a Container.Success
automatically and emits Container.Pending until the first value arrives:
data class State(
val number: Int = 0,
val extra: Boolean = false,
)
private val reducer: ContainerReducer<State> = getIntFlow() // Flow<Int>
.toContainerReducer(
initialState = ::State, // () -> State (constructor reference)
nextState = State::copy,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
)
val stateFlow: StateFlow<Container<State>> = reducer.stateFlow
If you don't need to update the state manually, nextState is optional:
private val reducer: ContainerReducer<State> = getIntFlow()
.toContainerReducer(
initialState = ::State,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
)
From a Flow of Containers
When the source already emits Container<T>, use containerToReducer:
data class State(
val items: List<Item>,
val extra: Boolean = false,
)
private val reducer: ContainerReducer<State> =
itemsRepository.getItems() // Flow<Container<List<Item>>>
.containerToReducer(
initialState = ::State,
nextState = State::copy,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
)
The container states (Pending, Error, Success) are mirrored directly
into the resulting StateFlow<Container<State>>.
Combining Multiple Flows
combineToReducer
Combine two or more flows into a single Reducer<State>. Each flow value
is merged into the state using the provided nextState function:
data class State(
val number: Int = 0, // from flow1
val text: String = "", // from flow2
val local: Boolean = false, // updated manually
)
private val reducer: Reducer<State> = combineToReducer(
flow1 = getNumberFlow(), // Flow<Int>
flow2 = getTextFlow(), // Flow<String>
initialState = State(),
nextState = State::copy,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
)
Overloads exist for up to five input flows. For more flows, use the list overload:
private val reducer: Reducer<State> = combineToReducer(
flows = listOf(flow1, flow2, flow3),
initialState = State(),
nextState = { state, values -> state.copy(...) },
scope = viewModelScope,
started = SharingStarted.Lazily,
)
combineToContainerReducer
Same as above, but wraps the result in Container<State>:
private val reducer: ContainerReducer<State> = combineToContainerReducer(
flow1 = getIntFlow(), // Flow<Int>
flow2 = getStringFlow(), // Flow<String>
initialState = ::State,
nextState = State::copy,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
)
The resulting StateFlow<Container<State>> emits Pending until all input
flows have produced at least one value.
combineContainersToReducer
When the input flows are themselves Flow<Container<T>>, use
combineContainersToReducer. Container states are merged using the same
rules as combineContainerFlows: a single Pending or Error input
makes the result Pending or Error respectively:
data class State(
val number: Int = 0, // from flow1: Flow<Container<Int>>
val text: String = "", // from flow2: Flow<Container<String>>
)
private val reducer: ContainerReducer<State> = combineContainersToReducer(
flow1 = getContainerIntFlow(), // Flow<Container<Int>>
flow2 = getContainerStringFlow(), // Flow<Container<String>>
initialState = ::State,
nextState = State::copy, // optional, omit if no manual updates needed
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
)
Manual State Updates
All reducer types provide an update method for applying changes that don't
come from the source flow:
// Reducer<State>:
reducer.update { oldState -> oldState.copy(filterEnabled = true) }
// ContainerReducer<State>: update the whole container:
reducer.update { oldContainer -> successContainer(someNewState) }
// ContainerReducer<State>: update just the value (no-op if not Success):
reducer.updateState { oldState -> oldState.copy(isLoading = false) }
Manual updates and flow emissions are safe to call from any coroutine.
ReducerOwner Interface
Supplying scope and started to every reducer constructor is repetitive.
The ReducerOwner interface lets you define them once and then omit them at
every call site.
To make it work, enabling Kotlin Context Parameters first is required for the context-parameter overloads:
// build.gradle
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xcontext-parameters")
}
}
Setting Up AbstractViewModel
Create an abstract base ViewModel that implements ReducerOwner:
abstract class AbstractViewModel : ViewModel(), ReducerOwner {
override val reducerCoroutineScope: CoroutineScope
get() = viewModelScope
override val reducerSharingStarted: SharingStarted
get() = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000)
}
Simplified Reducer Creation
Once AbstractViewModel is in place, child view-models no longer need to
pass scope or started:
class ItemListViewModel @Inject constructor(
private val itemsRepository: ItemsRepository,
) : AbstractViewModel() {
data class State(val items: List<Item> = emptyList())
private val reducer: ContainerReducer<State> =
itemsRepository.getItems() // Flow<Container<List<Item>>>
.containerToReducer(
initialState = ::State,
nextState = State::copy,
)
val stateFlow: StateFlow<Container<State>> = reducer.stateFlow
fun deleteItem(id: Long) {
reducer.updateState { it.copy(items = it.items.filter { item -> item.id != id }) }
}
}
The same shorthand is available for all combining functions:
// within a ReducerOwner (e.g. AbstractViewModel and its children):
private val reducer = combineToContainerReducer(
flow1 = getIntFlow(),
flow2 = getStringFlow(),
initialState = ::State,
nextState = State::copy,
)
Additionally, ReducerOwner provides short variants of stateIn and
shareIn:
val derived: StateFlow<String> = someFlow.stateIn(initialValue = "")
val shared: SharedFlow<String> = someFlow.shareIn(replay = 1)
Public Interface / Private Implementation Pattern
A useful pattern for keeping your ViewModel's public API clean is to separate
the public State interface from a private implementation class. The
implementation holds all private fields and computes the public properties
on the fly, so no mapping step is required:
// Public interface - only expose what the UI needs:
interface State {
val displayItems: List<String>
}
// Private implementation - holds full internal state:
private data class StateImpl(
val rawItems: List<String> = emptyList(),
val isUppercase: Boolean = false,
) : State {
override val displayItems: List<String>
get() = if (isUppercase) rawItems.map { it.uppercase() } else rawItems
}
// Reducer typed to the private class, but the public StateFlow uses the interface:
private val reducer: Reducer<StateImpl> = getItemsFlow()
.toReducer(
initialState = StateImpl(),
nextState = StateImpl::copy,
)
val stateFlow: StateFlow<State> = reducer.stateFlow // StateImpl is a State
This approach works because StateImpl implements State, so
StateFlow<StateImpl> is a StateFlow<State> via Kotlin's type variance.
API Reference
Reducer
Reducer<State>
stateFlow: StateFlow<State>
update(transform: (State) -> State)
ContainerReducer
ContainerReducer<State> // extends Reducer<Container<State>>
stateFlow: StateFlow<Container<State>>
update(transform: (Container<State>) -> Container<State>)
updateState(transform: (State) -> State) // no-op if not Success
Flow to Reducer conversion functions
Flow<T>.toReducer(initialState, nextState?, scope, started) -> Reducer<State>
Flow<T>.toContainerReducer(initialState?, nextState?, scope, started) -> ContainerReducer<State>
Flow<Container<T>>.containerToReducer(initialState?, nextState?, scope, started) -> ContainerReducer<State>
Combine functions
combineToReducer(flow1, flow2, ..., initialState, nextState, scope, started) -> Reducer<State>
combineToContainerReducer(flow1, flow2, ..., initialState, nextState?, scope, started) -> ContainerReducer<State>
combineContainersToReducer(flow1, flow2, ..., initialState, nextState?, scope, started) -> ContainerReducer<State>
All combine functions have overloads for 2–5 input flows and a list-based overload for an arbitrary number.
When used inside a ReducerOwner, scope and started can be omitted from
all of the above functions.