Skip to main content

04 - Presentation

Concept​

1. Why​

A page's logic needs three things:

  • one immutable state it renders from;
  • a way to receive the user's intents;
  • a way to fire one-time outcomes (a snackbar, a navigation callback) that must not replay when the page rebuilds.

Plain view-model methods called straight from the UI blur these. Decisions about "should this run now" end up scattered across callbacks, several intents duplicate the same work, and one-time outcomes leak into state as flags someone has to reset.

sinew_presentation gives every page the same small shape: Event → Action → State + Effect. It's framework-neutral: no Riverpod, no AndroidX. The Adapters bind it to a framework.

2. Shape​

Events and actions​

Events are what the page reports: the user's or the system's intent. Actions are the work the view model runs. They're two types because several intents often mean the same work, and some intents mean no work right now:

EventAction
OpenedLoad(Initial) (paged slices: the pager ignores a repeat) or LoadHome (plain slices: only the first time, see below)
RefreshedLoad(Refresh)
RetryTappedLoad( whichever load failed )
EndReached while another page existsLoad(More)
EndReached on the last pagenothing
CancelTapped(orderId)Cancel(orderId)
RuleWhy
Events and actions are sealed hierarchies of immutable valuesExhaustive matching in mapEventToAction and onAction
Events are named for what happened (…Tapped, …Changed, …Reached, Opened); actions for the work (Load, Cancel)Events stay honest about the user. Actions have one code path per kind of work.
mapEventToAction is pure: it reads the state and returns an action or nothingEvery "should this run now" decision is in one testable function
The page only sends events. It never calls an action.The page states intent; the view model decides what runs

State​

One immutable value per page. Each async slice is a ViewState (or a PagingState), with plain values (a query, a selected filter) beside them:

OrderListState(
orders: PagingState<Order> = PagingState(), // starts Loading: skeleton from the first frame
query: String = "",
statusFilter: OrderStatus? = null,
cancelling: Boolean = false, // an action at rest is just "not running"
)
RuleWhy
One container with slices, not one subclass per screen stateSeveral things load independently: "the list is loaded and a cancel is running" needs both at once
State changes only through setState { copy(…) }Immutable updates. The page rebuilds from one value.
No UI context, controllers, streams, timers or pre-rendered text in stateState is a plain value a test can compare. Errors stay AppExceptions, rendered at display time.
Never trigger a side effect from a state change. A snackbar, toast, dialog or navigation is an effect, emitted once.A state is rendered again on every change elsewhere in the page. A side effect tied to a state (say, a snackbar on Failed) would fire again each time.
A slice loaded when the page opens starts as Loading(). A page with several slices starts them all Loading() and loads them in parallel.The first frame is already a skeleton, and each section appears as soon as its own data arrives.

Loading on open, once​

A slice loaded on open starts as Loading(), so the state alone can't tell "the load already started" from "about to start". The opening event is guarded another way:

private var opened = false                                          // in the view model, not in state

mapEventToAction(Opened) = if (opened) null else { opened = true; LoadHome }
onAction(LoadHome) = parallel { loadBanner(); loadOrders(); loadPromos() } // each slice: Loading → Done or Failed on its own

The flag lives in the view model, so it survives a rotation along with it. A new page gets a new view model, and loads again.

Two kinds of async work​

Loading data to showPerforming an action (submit, cancel, delete)
SliceViewState<Data> or PagingState<T>a Boolean (cancelling, submitting)
While runningskeleton, or a refresh indicatora spinner inside the button, the button disabled
On successDone(data)back to false, plus a success effect
On failureFailed(error) rendered in the pageback to false, plus an error effect carrying the AppException

Loads fail into state, because the page must keep showing the problem. Actions fail as an effect, because the page is still valid and the user only needs to be told once.

Effects​

An effect is a one-time outcome: show a snackbar, open a dialog, call a navigation callback. It isn't state, because it must happen once and must not replay on rebuild.

RuleWhy
Effects go through an EffectEmitter and are collected by the pageOne channel per page, released when the page goes away
An effect emitted while no one is collecting is buffered and delivered once, to the next collectorA snackbar emitted during a rotation or a brief pause isn't lost, and isn't shown twice
An error effect carries the AppException, never textThe page renders it with displayMessage(localizer) in the current language
View models never navigate. They emit an effect, and the page calls its navigation callback.Navigation needs UI context

Simple pages​

The full skeleton pays off when a page has several intents. Smaller pages use less:

PageShape
Static, or all state is widget-localno view model
One or two actions (a confirmation page, one toggle)StateEffectHandler: state and effects, with direct methods (submit(), resend()) and no event or action types
Three or more intents, or intents that map to the same workEventActionHandler: the full skeleton

When a simple page grows a third intent, it's converted to the full skeleton in the same change.

What stays in the app​

App-level state (the signed-in session, its role) and routing are the app's own. Sinew's presentation base is per page.

3. API​

abstract class StateEffectHandler<S, F>(initial: S, scope) {        // scope: Kotlin CoroutineScope; Dart: none
state: S // read; observable (Kotlin StateFlow, Dart ValueListenable)
effects: stream of F // Kotlin Flow, Dart Stream
protected setState(reduce: S -> S)
protected emitEffect(effect: F)
dispose() // closes the effect stream; later calls do nothing
}

abstract class EventActionHandler<S, E, A, F>(initial: S, scope) : StateEffectHandler<S, F> {
onEvent(event: E) // mapEventToAction, then onAction if non-null
protected abstract mapEventToAction(event: E): A? // pure
protected abstract onAction(action: A) // Kotlin: suspend, launched in scope. Dart: async.
}

class EffectEmitter<F> {
emit(effect: F) // buffered until collected, delivered once
stream / flow
dispose()
}
NameFor
StateEffectHandler<S, F>a simple page: state and effects, direct methods
EventActionHandler<S, E, A, F>a full page: events, actions, state, effects
EffectEmitter<F>the buffered, deliver-once effect channel both bases use, also usable alone
PlatformStateEffectsScope
KotlinStateFlow<S>Channel<F>(BUFFERED) exposed as Flow<F>a CoroutineScope passed in. onAction runs in scope.launch.
Darta ValueListenable<S>a stream that buffers while no one listensnone. onAction is an async method, not awaited by onEvent.

4. Build steps​

  1. EffectEmitter, test first:
    • an effect emitted before a collector exists is delivered to the first collector;
    • each effect is delivered once, even with a second collector later;
    • after dispose(), emit does nothing.
  2. StateEffectHandler:
    • setState updates and notifies;
    • emitEffect goes through the emitter;
    • dispose() closes it.
  3. EventActionHandler:
    • onEvent calls mapEventToAction, then onAction only for a non-null action;
    • a null action runs nothing;
    • events after dispose() are ignored.
  4. A sample order-list handler with fakes, tested end to end: events in, states and effects out, no framework.

Done when

  • Both bases and the emitter pass their tests on every target, with no framework dependency.
  • A page view model can be tested by sending events and reading state and effects, with no UI and no framework.
  • An effect emitted during a rotation is shown exactly once afterwards.

5. Edge cases​

CaseDecided behavior
An effect emitted with no collectorBuffered, delivered once to the next collector.
A second collector after the first got the effectIt doesn't get it again.
Events sent after dispose()Ignored. No action runs, no state changes.
A rotation re-sends Opened (Kotlin keeps the view model, the screen sends Opened again)Paged slices: the pager runs Initial at most once per query, so nothing reloads. Plain ViewState slices: the view model keeps a private opened flag (not in state) and runs its opening loads only the first time.
Two actions run at once (a load, then a cancel tap)Allowed: they touch different slices. Work that must not overlap is guarded in mapEventToAction by reading state (for example, ignore CancelTapped while cancelling is Loading).
A search typed quicklyThe view model debounces, and Pager drops stale responses (see Paging).
onAction throwsIt shouldn't: everything below returns Result. A throw is a bug in the view model's own code, and it propagates like any unhandled error, so it shows up in development and in crash reports.
An action finishes after the page closedKotlin: the scope is cancelled, so the action stops. Dart: setState and emitEffect after dispose() do nothing.

Implementation​

1. Why​

The base presentation classes are framework-neutral, so on Kotlin they can't use viewModelScope. They take a CoroutineScope instead, and run actions in it. This note gives the real classes, and the buffered, deliver-once EffectEmitter.

2. Shape​

public class EffectEmitter<F> {
private val channel = Channel<F>(Channel.BUFFERED)
public val effects: Flow<F> = channel.receiveAsFlow() // each effect goes to one collector, once
public fun emit(effect: F) { channel.trySend(effect) }
public fun dispose() { channel.close() }
}

public abstract class StateEffectHandler<S, F>(initial: S, protected val scope: CoroutineScope) {
private val _state = MutableStateFlow(initial)
public val state: StateFlow<S> = _state.asStateFlow()
protected val currentState: S get() = _state.value
private val emitter = EffectEmitter<F>()
public val effects: Flow<F> = emitter.effects
protected fun setState(reduce: S.() -> S) { _state.update(reduce) }
protected fun emitEffect(effect: F) { emitter.emit(effect) }
public open fun dispose() { emitter.dispose(); scope.cancel() }
}

public abstract class EventActionHandler<S, E, A, F>(initial: S, scope: CoroutineScope) : StateEffectHandler<S, F>(initial, scope) {
public fun onEvent(event: E) {
val action = mapEventToAction(event) ?: return
scope.launch { onAction(action) }
}
protected abstract fun mapEventToAction(event: E): A?
protected abstract suspend fun onAction(action: A)
}
RuleWhy
Channel(BUFFERED) + receiveAsFlow()An effect sent while nobody collects waits in the buffer, and goes to exactly one collector
trySend in emitEffectEmitting never suspends the caller. With a closed channel (after dispose), it does nothing.
scope.launch per actionActions run concurrently, guarded by state in mapEventToAction (base)
dispose() cancels the scopeRunning actions stop when the owner goes away

Loading on open, once​

private var opened = false

override fun mapEventToAction(event: HomeEvent): HomeAction? = when (event) {
HomeEvent.Opened -> if (opened) null else { opened = true; HomeAction.LoadAll }
…
}

override suspend fun onAction(action: HomeAction) = when (action) {
HomeAction.LoadAll -> coroutineScope {
launch { loadBanner() }; launch { loadOrders() }; launch { loadPromos() } // each slice: Loading → Done/Failed on its own
}
…
}

3. API​

NameKotlin
EffectEmitter<F>as above
StateEffectHandler<S, F>public abstract class StateEffectHandler<S, F>(initial: S, scope: CoroutineScope)
EventActionHandler<S, E, A, F>public abstract class EventActionHandler<S, E, A, F>(initial: S, scope: CoroutineScope)

The module depends only on kotlinx-coroutines-core.

4. Build steps​

  1. EffectEmitter, tested with Turbine:
    • an effect emitted before collection arrives at the first collector;
    • a second collector doesn't get it again;
    • emit after dispose does nothing.
  2. StateEffectHandler and EventActionHandler with a TestScope. Tests:
    • state updates;
    • null actions run nothing;
    • dispose cancels a running action;
    • events after dispose are ignored.
  3. The opened pattern in a sample home handler: Opened twice starts the parallel loads once.

Done when

  • The sample order-list handler runs in commonTest with no Android dependency.

5. Edge cases​

CaseDecided behavior
onEvent after disposeThe scope is cancelled, so launch does nothing.
An effect emitted between two collections (a configuration change)It waits in the channel's buffer for the next collector.
A full buffer (64 pending effects)trySend fails and the effect is dropped. A page never legitimately queues that many one-time effects.
Decided by default (revisit during implementation)
  • Channel.BUFFERED (64) for effects, dropping beyond that, rather than an unlimited channel that could grow without bound.