Live feed¶
Source: sample/shared/…/examples/livefeed/
The live-feed example is a search-and-stream feature demonstrating four async task patterns in a
single machine: debounce, long-running polling, fire-and-forget analytics, and exponential
back-off retry. It also shows how explicit cancel() and autoCancel complement each other for
clean job lifecycle management.
States¶
Idle — no query; waiting for the user to type
Active — search results visible; optionally streaming live updates
Failed — last search failed; showing retry UI
Four task patterns¶
Pattern 1 — Debounce (keyed, replaces on each keystroke)¶
Every QueryChanged action launches task("search"). Because the key is the same, each new
keystroke cancels the previous in-flight task before starting a new one — no separate debounce
timer needed:
on<FeedAction.QueryChanged> {
task("search") {
delay(300) // virtual time in tests; real time in production
runCatching { feedRepository.search(action.query) }
.onSuccess { dispatch(FeedAction.SearchCompleted(action.query, it)) }
.onFailure { dispatch(FeedAction.SearchFailed(action.query, it.message ?: "…")) }
}
transition(state.copy(query = action.query, isLoading = true, isLive = false))
}
The handler transitions immediately (non-blocking) while the debounced search runs in parallel.
Pattern 2 — Long-running polling loop (keyed, explicit start/stop)¶
GoLive starts a polling coroutine that runs indefinitely, fetching new items every 5 seconds.
PauseLive stops it explicitly. Both actions are handled inside Active, so the state type
doesn't change — autoCancel wouldn't help here. Explicit cancel("poll") is required:
on<FeedAction.GoLive> {
task("poll") {
while (true) {
delay(5_000)
runCatching { feedRepository.fetchSince(state.query, sinceTimestamp) }
.onSuccess { if (it.isNotEmpty()) dispatch(FeedAction.NewItems(it)) }
}
}
transition(state.copy(isLive = true))
}
on<FeedAction.PauseLive> {
cancel("poll")
transition(state.copy(isLive = false))
}
When the machine leaves Active entirely (e.g. to Failed), the SearchFailed handler
explicitly calls cancel("poll") before transitioning — there is no onExit for this, since
the polling is intentionally state-scoped to Active.
Pattern 3 — Fire-and-forget analytics (unkeyed, machine lifetime)¶
Analytics tracking uses an unkeyed task { }. Unkeyed tasks are not stored in the registry —
they run until they finish regardless of state changes, and cannot be cancelled by the machine:
on<FeedAction.ItemViewed> {
task { runCatching { analyticsRepository.trackItemViewed(action.itemId) } }
// no transition — state is unchanged
}
Use unkeyed tasks for work you never need to cancel: analytics, logging, one-shot fire-and-forget RPCs.
Pattern 4 — Exponential back-off retry (keyed, replaces previous attempt)¶
When a search fails, the machine enters Failed and offers a Retry action. Each retry doubles
the backoff delay (capped at 30 seconds), and task("search") ensures that a late response
from a previous attempt is discarded if a new retry has started:
state<FeedState.Failed> {
on<FeedAction.Retry> {
val backoffMs = minOf(BASE_BACKOFF_MS shl state.retryCount, MAX_BACKOFF_MS)
val nextGeneration = state.retryCount + 1
task("search") {
delay(backoffMs)
runCatching { feedRepository.search(state.query) }
.onSuccess { dispatch(FeedAction.SearchCompleted(state.query, it)) }
.onFailure { dispatch(FeedAction.SearchFailed(state.query, it.message ?: "…", nextGeneration)) }
}
transition(state.toActive(query = state.query, items = emptyList(), isLoading = true, isLive = false))
}
// A late SearchCompleted from a previous attempt — accept it if it's still current
on<FeedAction.SearchCompleted> {
transition(state.toActive(query = action.query, items = action.items, isLoading = false, isLive = false))
}
// Increment retry count; SearchFailed carries the generation number
on<FeedAction.SearchFailed> {
transition(state.copy(message = action.message, retryCount = action.generation))
sideEffect(FeedEffect.ShowToast("Retry ${action.generation} failed — tap to try again"))
}
}
Stale result guard¶
When a search result arrives (SearchCompleted or SearchFailed), the handler checks that the
query in the action still matches the current state's query. If the user typed something new
while the search was in-flight, the result is silently discarded:
on<FeedAction.SearchCompleted> {
if (action.query != state.query) return@on // stale — discard
transition(state.copy(items = action.items, isLoading = false))
}
This is a common pattern for debounced async work where results may arrive out of order.
Parent catch-all for Clear¶
Clear is valid from any state. Rather than duplicating the handler in Idle, Active, and
Failed, it is registered on the parent state<FeedState> block. The explicit cancel calls
ensure any in-flight jobs are stopped immediately before transitioning to Idle: