Gbéewá is the library name. Its repository and artifact use gbeewa, and its Kotlin package is com.quantipixels.gbeewa.
Gbéewá delivers typed, transient results to app-wired Compose Multiplatform consumers. It does not own navigation, identify a previous destination, or select a destination. A mounted consumer can receive a result on the same screen or after any navigation change while the shared result store remains alive.
Quanti Pixels authors, maintains, and publishes this library. The library originated from the result mechanism in Yétúndé The Alárìná.
Version 0.1.0 supports Android and iOS. Future platform targets are added by request.
Result describes the outcome of an action. It does not define a navigation direction.
ResultStore.put stores one pending value under a key. It does not address a screen. The first eligible consumer that uses a ResultConsumer backed by the same store and an equal key consumes that value. Therefore, a publication can deliver in these directions:
- current to previous: publish, then pop;
- current to current: publish while the consumer remains mounted;
- current to next: publish, then push a screen that consumes the key.
Other route relationships use the same rules. Navigation direction does not select the receiver. Shared ResultStore ownership, key equality, and consumption timing select it. Delivery is not a broadcast. If more than one consumer can observe an equal key, only the first consumer receives the pending value. A mounted consumer can consume it before a later screen enters composition. Use request-specific or parameterized keys when the receiver must be unambiguous.
The runnable Android and iOS showcase uses two flows derived from Yétúndé:
- a result from a pushed edit screen;
- two independent selection results from one shared producer, separated by a receiver ID.
These flows also cover country selection, dropdowns, conversation starters, media selection, audio recording, and chat actions because they use the same publish-before-dismiss contract.
One shared samples:composeApp module contains real integrations for Decompose, typed Navigation 2, and Voyager. Each integration uses a real back stack. The app root owns ResultStore. Route callbacks publish before they pop. Screen composables receive plain value callbacks and do not receive the store. A parameterized key keeps the two selection receivers separate.
The app uses the library toolchain and depends directly on :gbeewa. It builds the Android application and the GbeewaShowcase frameworks for iosArm64, iosSimulatorArm64, and iosX64.
Open the repository root in Android Studio and sync it. Run the composeApp Android application. For iOS, open samples/iosApp/iosApp.xcodeproj and run the iosApp scheme. Both hosts render the same ShowcaseApp composable. The iOS host targets iOS 17.2.
Build and test the showcase with:
./gradlew \
:samples:composeApp:testDebugUnitTest \
:samples:composeApp:iosSimulatorArm64Test \
:samples:composeApp:assembleDebug \
:samples:composeApp:linkDebugFrameworkIosArm64 \
:samples:composeApp:linkDebugFrameworkIosSimulatorArm64Add Maven Central to the consumer build. Then add the library to commonMain:
commonMain.dependencies {
implementation("com.quantipixels:gbeewa:<version>")
}Define a typed key. Create one store and consumer for the required communication lifetime. Keep the store at the navigation root. Provide only the consumer through the composition local:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import com.quantipixels.gbeewa.LocalResultConsumer
import com.quantipixels.gbeewa.ResultConsumer
import com.quantipixels.gbeewa.ResultKey
import com.quantipixels.gbeewa.ResultStore
object SelectedCountryKey : ResultKey<String>
@Composable
fun App() {
val resultStore = remember { ResultStore() }
val resultConsumer = remember(resultStore) { ResultConsumer(resultStore) }
CompositionLocalProvider(LocalResultConsumer provides resultConsumer) {
AppNavigation(resultStore)
}
}Receive the result as Compose state:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import com.quantipixels.gbeewa.rememberResultAsState
@Composable
fun CountryRoute(openPicker: () -> Unit) {
val selectedCountry by rememberResultAsState(SelectedCountryKey) { "Ireland" }
CountryScreen(
selectedCountry = selectedCountry,
onSelectCountry = openPicker,
)
}Publish before the navigation action that completes the handoff. The picker screen receives a plain callback and does not know about Gbéewá:
@Composable
fun CountryPickerRoute(
resultStore: ResultStore,
pop: () -> Unit,
) {
CountryPickerScreen(
onCountrySelected = { country ->
resultStore.put(SelectedCountryKey, country)
pop()
},
)
}The first release is built and verified with this matrix:
| Area | Support |
|---|---|
| Android | API 24 or later |
| Apple targets | iosArm64, iosSimulatorArm64, and iosX64 |
| Kotlin | 2.1.21 |
| Compose Multiplatform | 1.8.2 |
The showcase iOS application targets iOS 17.2. This value is the host application deployment target, not a library delivery rule.
| API | Purpose |
|---|---|
ResultKey<T> |
Defines a typed result lane. Key equality defines lane identity. |
ResultStore |
Publishes and retains the latest pending value for each equal key. |
ResultConsumer |
Provides consume-only access to a store. |
LocalResultConsumer |
Provides a consumer to Compose descendants. |
ResultEffect |
Consumes one pending result in a non-suspending callback. |
rememberResultAsState |
Converts delivered results into read-only Compose state. |
rememberSaveableResultAsState |
Converts delivered results into read-only saveable Compose state with automatic or custom saving. |
Key equality defines the result lane. An object is suitable for one shared lane. A data class is suitable when an identifier is part of the lane.
import com.quantipixels.gbeewa.ResultKey
object SelectedCountryKey : ResultKey<String>
data class EditedProfileKey(val profileId: String) : ResultKey<Boolean>The key type controls the payload type at normal call sites.
Create one ResultStore at the application or navigation root. Keep it for the communication lifetime that the app needs. Gbéewá does not require the store to be exposed to screen content.
The app chooses how far each capability travels:
- Keep
ResultStoreat the root and capture it in route-owned callbacks. - Pass or inject
ResultStoreinto a producer when direct publication is useful. - Provide
ResultConsumerthroughLocalResultConsumerforResultEffectand the result-to-state helpers. - Keep
ResultConsumerbeside the store and pass it explicitly when composition-local access is not appropriate.
This scaffold provides only the consume capability to its descendants. The content lambda does not receive the store:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import com.quantipixels.gbeewa.ResultConsumer
import com.quantipixels.gbeewa.ResultStore
import com.quantipixels.gbeewa.LocalResultConsumer
@Composable
fun AppScaffold(
resultStore: ResultStore,
content: @Composable () -> Unit,
) {
val resultConsumer = remember(resultStore) {
ResultConsumer(resultStore)
}
CompositionLocalProvider(
LocalResultConsumer provides resultConsumer,
// Keep the app's other existing providers here.
) {
content()
}
}The route or application callback that publishes needs access to ResultStore. The screen that produces the value does not need access when it reports the value through a callback. The receiving route needs a ResultConsumer, usually through LocalResultConsumer.
The remember example keeps the store across recomposition. It does not keep the store when Android recreates the activity after a configuration change.
Keep ResultStore in an app-owned ViewModel when pending results must survive configuration changes. ResultConsumer is a small facade over the store. It does not require separate lifecycle ownership. You can create it with remember(resultStore), or keep it in the same ViewModel when that is more convenient:
import androidx.lifecycle.ViewModel
import com.quantipixels.gbeewa.ResultConsumer
import com.quantipixels.gbeewa.ResultStore
class AppResultViewModel : ViewModel() {
val resultStore = ResultStore()
val resultConsumer = ResultConsumer(resultStore)
}Provide resultConsumer through LocalResultConsumer. Capture resultStore in route callbacks, or pass it to producers when the application chooses direct exposure. A ViewModel preserves this in-memory owner across Android configuration changes. It does not restore pending results after process death.
The application also chooses where the receive API appears. Install ResultEffect in a route when screen content must remain unaware of Gbéewá. Install it directly in a screen when that coupling is useful:
import androidx.compose.runtime.Composable
import com.quantipixels.gbeewa.ResultEffect
@Composable
fun CountryRoute(
onCountrySelected: (String) -> Unit,
content: @Composable () -> Unit,
) {
ResultEffect(SelectedCountryKey, onCountrySelected)
content()
}The route can pass ordinary values and callbacks to its screen content. The screen does not need a ResultConsumer, ResultKey, or ResultStore.
The handler is not a suspend function. Keep it quick. Update state directly, or launch longer work in a caller-owned scope that has the correct lifecycle and error policy.
Use rememberResultAsState when the result directly updates local Compose state. The AsState suffix distinguishes the returned State<T> from the root-owned ResultStore. Extra remember keys reset the initial value when their context changes:
val selectedCountry = rememberResultAsState(
resultKey = SelectedCountryKey,
userId,
) {
profileCountry
}Use rememberSaveableResultAsState when the destination must restore its initial or last delivered UI value through Compose saveable state:
val selectedCountry = rememberSaveableResultAsState(
resultKey = SelectedCountryKey,
) {
profileCountry
}The automatic saver supports values accepted by the current SaveableStateRegistry. The helper preserves a delivered nullable result, including null. Keep saved values small and destination-scoped.
Provide a value-level Saver when the result type is not supported automatically:
val selectedCountry = rememberSaveableResultAsState(
resultKey = SelectedCountryKey,
saver = CountrySaver,
) {
profileCountry
}The custom saver overload passes CountrySaver to Compose as the mutable state's stateSaver. The overload without saver continues to use Compose automatic mutable-state saving.
This saveable helper does not save ResultStore, pending keys, or pending results. It restores the receiver's UI value without consuming or replaying a result. Live input changes reset the state. Compose restoration does not validate the inputs that existed when it saved the value.
Do not mount ResultEffect and either result-to-state helper for the same key when one receiver must own delivery.
A reusable screen can expose a value callback without depending on Gbéewá or a navigation framework. The route layer decorates that callback with publication and navigation:
@Composable
fun CountryPickerRoute(
resultStore: ResultStore,
pop: () -> Unit,
) {
CountryPickerScreen(
onSuccess = { country ->
resultStore.put(SelectedCountryKey, country)
pop()
},
)
}CountryPickerScreen knows only onSuccess(country). The route owns the store, the result key, the navigation action, and the required publish-before-pop order.
The same pattern works with a ViewModel, Decompose component, presenter, or other app-level owner. The callback can publish and then pop, push, replace, dismiss, or remain on the current screen.
An application can pass or inject ResultStore into a producer when that boundary is more useful. Gbéewá does not enforce one exposure level:
fun selectCountry(
resultStore: ResultStore,
country: String,
dismiss: () -> Unit,
) {
resultStore.put(SelectedCountryKey, country)
dismiss()
}In both styles, publish the result before the application action that completes the handoff. Navigation is optional.
Do not publish when the intended receiver will not remain active or become active while the shared ResultStore still exists. For example, a flow that returns to the app root must not publish a result intended for an intermediate screen. If another mounted consumer uses an equal key, it can consume the value first.
Null is a valid result only when the key declares a nullable payload:
object OptionalNoteKey : ResultKey<String?>
resultStore.put(OptionalNoteKey, null)Presence is stored separately from the value. A pending null is delivered once. It is not treated as no result.
- The store keeps only the latest pending value for each equal key.
- Consumption removes the value before the handler runs.
- One result is consumed at most once, including when multiple effects compete for one key.
- A handler failure does not restore the result.
- A handler must not publish another value for its own key. That value can remain pending until the effect leaves and re-enters composition.
- A pending result remains available when no receiver is mounted.
- The library does not remove stale results automatically.
- The root owner discards pending results when it discards the
ResultStore. - The saveable helper can restore a receiver's delivered UI value. It does not restore pending results.
- Any app layer can own the store or call
put, including aViewModelor navigation component. The call must execute on the UI or main context that drives Compose consumption. The library does not switch contexts or synchronize concurrent access.
Open an issue before a behavior change or new platform target. Keep navigation policy outside the library, add tests for contract changes, and update CHANGELOG.md for public behavior changes. Before a pull request, run:
./gradlew check apiCheck koverVerifyContributions are licensed under Apache-2.0.
Gbéewá is available under the Apache License 2.0. See LICENSE.