Node → Host → Screen Layering
Keep the reactive stack readable and testable by splitting responsibilities:
- Node (headless,
contacts.flow) – state/events/outputs/effects only. Pure Kotlin, shared with tests and other UIs. - Host (Compose,
contacts.ui) – collects state/effects, handles transient UI (snackbars/dialogs), wires DI/subflows, and forwards clean callbacks. - Screen (Compose,
contacts.ui) – pure UI,state in → events out; no side effects or DI.
When to use:
- Any flow where you want headless logic + thin UI glue + pure UI, including single-node tabs.
- Teams that need predictable layering and easy grep-able imports across features.
Naming & packages
- Stick to
FooNode,FooHost,FooScreen. - Keep nodes/NavFlow in a
*.flowpackage; keep UI/hosts in*.ui. This makes renderer imports and grep-friendly searches predictable.
Example (from the sample app)
// contacts.flow
class ContactsNavFlow(...) : NavFlow<ContactsFlowEvent, DefaultStackEntry<ContactsFlowEvent>>(
appScope = appScope,
rootNode = ContactsListNode(repository, appScope),
navigatorFactory = { entry -> KmposableStackNavigator(entry) }
)
// contacts.ui
@Composable
fun ContactsListHost(node: ContactsListNode) {
val state by node.state.collectAsState()
ContactsListScreen(state = state, onEvent = node::onEvent)
}
@Composable
fun ContactDetailsHost(node: ContactDetailsNode) {
val state by node.state.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
CollectEffects(node) { effect ->
if (effect is ContactDetailsEffect.ShowMessage) snackbarHostState.showSnackbar(effect.text)
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
ContactDetailsScreen(state, node::onEvent, Modifier.padding(padding))
}
}
// App renderer wiring
val renderer = remember {
nodeRenderer {
register<ContactsListNode> { ContactsListHost(it) }
register<ContactDetailsNode> { ContactDetailsHost(it) }
register<EditContactNode> { EditContactHost(it) }
}
}
NavFlowHost(navFlow = navFlow, renderer = renderer)
Tips
- Put persistent errors in state; use effects for one-shot signals (snackbar/toast/nav).
- Result-only overlays: use
presentation = Presentation.Overlayon nodes and render viaOverlayNavFlowHost; register withregisterResultOnly. - Keep hosts thin; if wiring grows (subflows, DI helpers), extract small helpers rather than bloating screens.
- Parent/child state sync: when a parent owns a child node and needs to mirror its state, use
mirrorChildState(child.state) { parent, child -> parent.copy(childState = child) }inside the parent node instead of manual plumbing. - Tests exercise nodes/navflow directly via
FlowTestScenario; screens stay trivial to preview.