Skip to content

Handlers

Every on<ActionType> lambda receives ActionScope as its implicit receiver, giving access to state (the current state cast to the registered subtype), action (the dispatched action), and a set of verb methods that record what the runtime should do.

Handlers are statements, not expressions — the lambda returns Unit. The runtime snapshots the recorded result after the lambda returns.


Handler patterns

Inline suspend (blocking)

The handler coroutine suspends until it returns. The action queue is paused for the duration — exactly one request is processed at a time. Good for simple, fast operations.

on<LoginAction.Submit> {
    val result = loginRepository.login(state.username, state.password)
    when (result) {
        is Success -> transition(LoginState.Authenticated(result.user))
        is Failure -> transition(LoginState.Error(result.message))
    }
}

Fire-and-dispatch (non-blocking)

Return immediately, run work in a sibling coroutine, and dispatch a follow-up action when complete. The action queue is free to process other actions while the task runs.

on<LoginAction.Submit> {
    task("login") {
        val r = loginRepository.login(state.username, state.password)
        dispatch(
            if (r is Success) LoginAction.LoginSucceeded(r.user)
            else LoginAction.LoginFailed(r.message)
        )
    }
    transition(LoginState.Submitting)
}

Keyed jobs (debounce / cancel)

Passing a string key to task cancels any running job with the same key before launching a new one. Use this for debounce, retry, or explicit cancellation from another handler.

on<SearchAction.QueryChanged> {
    task("search") {
        delay(300)
        dispatch(SearchAction.ResultsReceived(repository.search(action.query)))
    }
    transition(state.copy(query = action.query, isLoading = true))
}

on<SearchAction.Clear> {
    cancel("search")
    transition(SearchState.Idle)
}

Pass autoCancel = true to have the runtime cancel the job automatically on the next state-type change, just before onExit fires:

on<LoginAction.Submit> {
    task("login", autoCancel = true) {
        // cancelled automatically if the state type changes before this finishes
        dispatch(LoginAction.LoginSucceeded(repo.login(state.username, state.password)))
    }
    transition(LoginState.Submitting)
}

Custom coroutineScope for tasks

By default, task { } launches inside machineScope — the coroutine scope tied to the store's lifetime. Pass a shorter-lived scope to automatically cancel the task when that scope is cancelled, independently of state-type changes or explicit cancel() calls:

// Keyed task scoped to the current request's scope — cancelled when the request completes
on<DownloadAction.Start> {
    task("download", coroutineScope = requestScope) {
        val bytes = fileRepository.download(state.url)
        dispatch(DownloadAction.Completed(bytes))
    }
    transition(DownloadState.Downloading)
}

This is useful when a task's lifecycle is tied to an external resource (a network call, an open socket, a scoped DI component) that can be cancelled independently of the machine.


Handler verbs

Verb Behaviour
transition(newState) Record the next state. First call wins — subsequent calls in the same handler are silent no-ops.
sideEffect(e1, e2, …) Append effects; emitted in call order. If no transition() is recorded, state remains unchanged (effect-only handler).
reject() Mark the action as rejected. Terminal — all subsequent verb calls become no-ops, and any effects accumulated before this call are also discarded. Plugins are notified via onRejected.
guard { predicate } Short-circuit all subsequent verbs if predicate returns false. Unlike reject(), pre-guard recordings are preserved and plugins are not notified.
dispatch(action) Enqueue an action on the store's channel for later processing.
task { } / task("key") { } Launch a fire-and-forget coroutine, optionally keyed for cancellation.
cancel("key") Cancel the running job with the given key. No-op if no job is running.

First-write-wins for transition

Use fallback patterns without if/else:

on<Refresh> {
    if (state.isStale) transition(Refreshing)
    transition(Active)   // ignored if transition() was already called above
}

If you need exclusive selection, use if/else so only one branch is reachable.

Terminal reject

on<Submit> {
    if (!state.isValid) { reject(); return@on }
    transition(Submitting)
    sideEffect(Analytics.Started)
}

reject() is terminal and unconditional: the runtime discards everything recorded before the call — state, effects, and any pending dispatches. Nothing is emitted and the state is unchanged. If you want effects recorded before a failing predicate to still emit, use guard instead.

Effect-only handlers

Calling sideEffect() without transition() is a valid and intentional pattern. The state stays unchanged and the effects are emitted normally:

on<CartAction.Ping> {
    sideEffect(CartEffect.Toast("Cart is ready"))   // state unchanged; effect emitted
}

This is useful for actions that need to communicate a one-shot event to the UI without changing the machine's state — validation feedback, accessibility announcements, analytics pings, and similar fire-and-forget notifications.

Multiple effects work the same way:

on<AuthAction.SessionExpired> {
    sideEffect(
        AuthEffect.Toast("Session expired"),
        AuthEffect.ClearLocalCache,
    )
    // No transition — state stays as-is; both effects emit in call order
}

guard — conditional short-circuit

guard { predicate } stops recording further results if predicate returns false. Unlike reject(), any verbs called before the guard are preserved and will be applied — and plugins are not notified. Use it when partial work should still take effect even if the main transition shouldn't happen:

on<MyAction.Submit> {
    sideEffect(MyEffect.Analytics)   // always emitted — recorded before the guard
    guard { state.isValid }          // short-circuits everything below when invalid
    transition(MyState.Submitting)
    sideEffect(MyEffect.HideKeyboard)
}

When state.isValid is false: only Analytics is emitted; no transition, no HideKeyboard. When state.isValid is true: all three verbs apply.

Compare with reject(), which discards everything (including pre-reject side effects) and notifies plugins:

Pre-call verbs Plugins notified
guard { false } preserved no
reject() discarded yes (onRejected)

Calling reject() after a failing guard is a no-op — guard semantics take precedence.