Core
This is the core module for the Orbit framework. It provides all the basic parts of Orbit.
You will need this module (or modules that include it) to get started!
implementation("org.orbit-mvi:orbit-core:12.0.0")
See architecture if you're interested in learning more about MVI and how its concepts map onto Orbit's components.
Orbit container
A
OrbitContainer
is the heart of the Orbit MVI system. It retains the state, allows you to listen
to side effects and state updates and allows you to modify the state through the
orbit function which executes Orbit operators of your desired business logic.
Subscribing to the container
OrbitContainer exposes flows that emit updates to the container state and side effects.
- State emissions are conflated
- Side effects are cached by default if no observers are listening. This can be changed via Settings Builder
data class ExampleState(val seen: List<String> = emptyList())
sealed class ExampleSideEffect {
data class Toast(val text: String)
}
class ExampleContainerHost(scope: CoroutineScope) : OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
// create a container
override val container = scope.orbitContainer<ExampleState, ExampleSideEffect>(ExampleState())
fun doSomethingUseful() = intent {
...
}
}
private val scope = CoroutineScope(Dispatchers.Main)
private val viewModel = ExampleContainerHost(scope)
fun main() {
// subscribe to updates
// On Android, use OrbitContainerHost.observe() from the orbit-viewmodel module
scope.launch {
viewModel.container.stateFlow.collect {
// do something with the state
}
}
scope.launch {
viewModel.container.sideEffectFlow.collect {
// do something with the side effect
}
}
viewModel.doSomethingUseful()
// Ensure the main function does not complete so we can do something useful with the container.
}
OrbitContainerHost
A OrbitContainerHost is not strictly required to work with an Orbit OrbitContainer. However, Orbit's syntax is defined as an extension on this class. Additionally it simplifies and organises your business logic and so is highly recommended. A OrbitContainerHost typically defines MVI flows (your business logic and Orbit operators to be invoked on the OrbitContainer) as functions that can be called by e.g. the UI.
In a typical implementation you would subclass Android's ViewModel and
implement
OrbitContainerHost
in order to create an Orbit-enabled Android ViewModel.
class ExampleViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel(), OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
// create a container
val container = orbitContainer<ExampleState, ExampleSideEffect>(ExampleState(), savedStateHandle)
…
}
External state
OrbitContainerHost supports separation between internal and external states
via its third type parameter. This is useful whenever the raw internal state is
not well suited to consumption outside of the OrbitContainerHost (e.g. by a UI).
Let's define an outside consumer as anything that consumes the state of the
OrbitContainerHost and is external to it - e.g. a UI.
Internal state is the raw OrbitContainerHost's state that is not well suited to
consumption outside of the OrbitContainerHost. It may contain lots of unnecessary
information and you may see the complexity bleeding into the UI. Ideally UIs
should not contain any business logic.
External state is the state that is exposed to the outside world. It is a mapped representation of the internal state that contains the minimum necessary data for outside consumers. The data is mapped in a way that is ready for consumption, avoiding the need for any additional processing in the UI.
To use external state, specify a different type for the external state parameter
of OrbitContainerHost and pass a transformState function to the container
factory:
class ExampleViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel(), OrbitContainerHost<ExampleState, ExampleExtState, ExampleSideEffect> {
// create a container with external state transformation
override val container = orbitContainer<ExampleState, ExampleExtState, ExampleSideEffect>(
ExampleState(),
savedStateHandle,
::transformState
)
…
private fun transformState(state: ExampleState): ExampleExtState {
// Process the internal state into something the UI can easily consume...
return ExampleExtState(...)
}
}
Core operators
The Core module contains built-in Orbit operators. Here's how they map to MVI concepts:
| MVI Operation | Orbit DSL | Purpose |
|---|---|---|
| block | intent { ... } | Contains business logic, allows you to invoke other operators within |
| transformation | operations within intent | Run business operations to transform data |
| posted side effect | postSideEffect(...) | Sends one-off events to the side effect channel |
| reduction | reduce { ... } | Atomically updates the OrbitContainer's state |
| - | repeatOnSubscription { ... } | Helps collect infinite flows only when there are active subscribers |
| - | subIntent { ... } | Use this to break big intent blocks into smaller parts, or for parallel decomposition. |
| - | runOn { ... } | Useful for working with sealed class states. The block is only executed if (and while) the state matches. |
| - | awaitRunOn { ... } | Like runOn, but suspends until the state matches instead of completing immediately. |
Operators are invoked through the intent block in a
OrbitContainerHost.
For more information about which threads these operators run on please see
Threading.
Transformation
class Example : OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
...
fun simpleExample() = intent {
anotherApiCall(apiCall()) // just call suspending functions
}
}
Transformations change upstream data into a different type. Transformations
can do a simple mapping or something more complex like call a backend API or
subscribing to a Flow.
Infinite Flows are best collected within a
repeatOnSubscription block.
In Orbit, the transformations are simply suspend function calls in the block
function. It is your responsibility to ensure you are using the correct
context for your calls. Blocking code in your intent block will generally
cause Orbit's "event loop" to be blocked, effectively preventing processing
of new intents until that code completes.
Reduction
class Example : OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
...
fun simpleExample(number: Int) = intent {
val result = apiCall()
reduce { state.copy(results = result) }
}
}
Reducers take incoming events and the current state to produce a new state.
Side effect
class Example : OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
...
fun simpleExample(number: Int) = intent {
val result = apiCall()
postSideEffect(ExampleSideEffect.Toast("result $result"))
reduce { state.copy(results = result) }
}
}
Working with any system will eventually generate side effects. We've made them a first class citizen in Orbit.
This functionality is commonly used for things like truly one-off events, navigation, logging, analytics etc.
You may post the side effect in order to send it to a OrbitContainer's side effect flow. Use this for view-related side effects like Toasts, Navigation, etc.
Side effects are cached if there are no observers, guaranteeing critical events such as navigation are delivered after re-subscription.
Delivery behaviour is controlled by SettingsBuilder.sideEffectMode:
SideEffectMode.FAN_OUT(default) — single-observer behaviour. Each side effect is delivered to exactly one collector; cached side effects are consumed by the first collector that connects. If multiple collectors are active, they silently compete for events.SideEffectMode.BROADCAST(experimental, opt-in via@OrbitExperimental) — side effects are broadcast to all active collectors, and cached side effects are replayed to every collector that connects. Shortly after the first subscriber connects, the replay cache is cleared so late joiners don't receive stale events. Collectors that take a long time to process items will back-pressure the emitter (and the other collectors) once the buffer fills.SideEffectMode.FAN_OUT_STRICT— strict single-observer fan-out behaviour. Each side effect is delivered to exactly one collector, and cached side effects are still consumed by the first collector that connects. A second collector now gets anIllegalStateExceptioninstead of silently competing for events.
Repeat on subscription
class Example : OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
...
fun simpleExample() = intent(idlingResource = false) {
repeatOnSubscription {
expensiveFlow().collect {
//
}
}
}
}
Collecting flows directly in an intent block continues until the flow
completes or cancels. Cancellation happens automatically when the Orbit
coroutine scope cancels.
The lifecycle of the Orbit coroutine scope, especially when set to
viewModelScope may outlive the lifecycle of the UI resulting in subscriptions
continuing in the background.
For expensive subscriptions, such as location or Bluetooth, this may be undesirable, you only want to collect from the flow when the UI actively observes the state or sideEffect streams.
repeatOnSubscription provides functionality to start (and restart) its inner
block when the state or sideEffect streams are being observed and stop when that
is no longer the case.
Sub-intent
class Example : OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
...
override val container = scope.orbitContainer<ExampleState, ExampleSideEffect>(
ExampleState()
) {
// Remember that this `onCreate` block is an implicit `intent`.
// A common pattern is to subscribe to various subintents collecting
// flows or other work here.
coroutineScope {
launch { smallIntent() }
launch { smallIntent2() }
launch { collectFlow() }
}
}
fun largeIntent() = intent {
smallIntent()
smallIntent2()
}
fun largeIntentWithParallelDecomposition() = intent {
coroutineScope {
launch { smallIntent() }
launch { smallIntent2() }
}
}
suspend fun smallIntent() = subIntent {
// ... do work
reduce { state.copy(...) }
}
suspend fun smallIntent2() = subIntent {
// ... do work
reduce { state.copy(...) }
}
suspend fun collectFlow() = subIntent {
someFlow.collect {
// ... do work
reduce { state.copy(...) }
}
}
}
Think of subIntent as a suspend function, but with access to Orbit syntax.
It is useful for breaking down large intent blocks into smaller parts, or for
parallel decomposition of work.
Most commonly, you would combine several subIntent calls in a single
onCreate block of the container factory. This is a common pattern when
collecting several flows or performing several parallel operations on creation.
RunOn
class Example : OrbitContainerHost<ExampleSealedClassState, ExampleSealedClassState, ExampleSideEffect> {
...
override val container =
scope.orbitContainer<ExampleSealedClassState, ExampleSideEffect>(
ExampleSealedClassState.Loading
)
fun withoutRunOn() = intent {
state as? ExampleSealedClassState.Ready?.let {
... run some operations with the captured state
}
}
fun withRunOn() = intent {
runOn<ExampleSealedClassState.Ready> {
... run some operations with the captured state
}
}
}
runOn is a helper DSL to improve working with sealed class states. It allows
you to run a block of code only when the state matches the provided type and
optionally the predicate.
Additionally, the block is automatically cancelled when the state no longer matches the provided type or predicate.
Note that there are no atomicity guarantees when using runOn. The block may
get executed partially.
AwaitRunOn
class Example : OrbitContainerHost<ExampleSealedClassState, ExampleSealedClassState, ExampleSideEffect> {
...
override val container =
scope.orbitContainer<ExampleSealedClassState, ExampleSideEffect>(
ExampleSealedClassState.Loading
) {
onCreate {
awaitRunOn<ExampleSealedClassState.Ready> {
... run some operations once the state becomes Ready
}
}
}
}
awaitRunOn behaves like runOn, with one difference: when the
current state does not match the provided type and optional predicate, it
suspends until it does, rather than completing immediately.
This is useful when you want to start work as soon as the container reaches a
particular state — for example, awaiting a Ready state in onCreate before
collecting a flow.
As with runOn, the block is automatically cancelled when the state no longer
matches the provided type or predicate, and there are no atomicity guarantees.
awaitRunOn is experimental and requires opting in to @OrbitExperimental.
Operator context
Each simple syntax operator lambda has a receiver that exposes the current state
of the
OrbitContainer
as state
perform("Toast the current state")
class Example : OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
...
fun anotherExample(number: Int) = intent {
val result = apiCall()
postSideEffect(ExampleSideEffect.Toast("state $state"))
reduce { state.copy(results = event.results) }
}
}
reduce is a special operator, where state is captured when its lambda is
invoked. This means that within a reduce block, your state is guaranteed
not to change.
The reduce block does not support calling suspend functions. Since reduce
is designed to be a pure, synchronous state transformation, all asynchronous
work (e.g. API calls, database queries) must be performed before the
reduce block.
// ✅ Correct - suspend call before reduce
fun loadData() = intent {
val result = apiCall() // suspend call here
reduce { state.copy(data = result) }
}
// ❌ Wrong - suspend call inside reduce (compilation error)
fun loadData() = intent {
reduce { state.copy(data = apiCall()) } // will not compile
}
OrbitContainer factories
perform("Toast the current state")
class Example : OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
override val container = orbitContainer<ExampleState, ExampleSideEffect>(ExampleState()) {
// This block is an intent invoked when the container is first created
reduce { ... }
}
}
Containers are typically not created directly but through convenient factory functions. This allows you to pass through extra settings or an intent lambda to invoke when the OrbitContainer is first created (important for containers that can be recreated from a saved state or live longer than the UI).
A typical use case for this is to collect Flows that we need to start
observing right after the container is created.
perform("Toast the current state")
class Example(
private val flow1: Flow<Int>,
private val flow2: Flow<Int>,
): OrbitContainerHost<ExampleState, ExampleState, ExampleSideEffect> {
override val container = orbitContainer<ExampleState, ExampleSideEffect>(ExampleState()) {
coroutineScope {
repeatOnSubscription {
launch {
flow1.collect {
reduce { ... }
}
launch {
flow2.collect {
reduce { ... }
}
}
}
}
}
Extra
OrbitContainer
factory functionality is provided via extension functions. One example is
ViewModel saved state support via a SavedStateHandle.
Global default settings
Every container's settings start from the Orbit library defaults and can be
overridden per-container via the buildSettings { } block. If you want a
particular setting (for example a sideEffectMode or a dispatcher) to apply
across your whole app without repeating it at every call site, configure the
global defaults once:
Orbit.configureDefaults {
sideEffectMode = SideEffectMode.FAN_OUT
intentLaunchingDispatcher = { Dispatchers.Default.limitedParallelism(1) }
}
The dispatcher settings are factories, invoked once per container. This
means each container gets its own dispatcher instance — in the example above,
every container gets its own limitedParallelism(1) view, so intents are
serialized within each container rather than across the whole app.
Settings are layered as:
- Orbit library defaults
Orbit.configureDefaults { }global overrides- per-container
buildSettings { }overrides (always win)
Orbit.configureDefaults should be called once at app startup, before any
containers are created. The global default is held in a single @Volatile
field with no further synchronization, so configuring it after containers exist
leads to undefined behaviour. Each call starts from the library defaults, so
repeated calls are idempotent rather than cumulative. Orbit.resetDefaults()
restores the library defaults and is mainly useful in tests.
Containers created through orbit-test's test() helper deliberately ignore
the global defaults to keep tests deterministic.
Threading
Orbit is designed to provide a sane default threading model to cater for most of the typical use cases. That being said you are not constrained and are free to switch threads if you need to (e.g. for database access). Typically that is done by switching your coroutine context.
Threading guarantees
- Calls to
OrbitContainer.intentdo not block the caller. The operations within are offloaded to an event-loop style background coroutine. - Generally it is good practice to make sure long-running operations are done in a switched coroutine context in order not to block the Orbit "event loop".
Error handling
It is good practice to handle all of your errors within your intents.
By default Orbit doesn't handle or process any exceptions because it cannot
make assumptions about how you respond to errors. However you could install
default exception handler via
Settings Builder
property exceptionHandler -> if defined exceptions are caught here so
parent scope is not affected and Orbit container
would continue to operate normally.