FAQ & Troubleshooting¶
My onEnter for the initial state never fires¶
onEnter is not called for the initial state when a store is first constructed. The
processing loop fires it lazily the first time a collector subscribes to state, actions, or
effects — which is normally fine for UI-driven machines because the screen subscribes
immediately.
If you need onEnter to fire before any collector subscribes (e.g. in a background ViewModel),
call store.start() explicitly after construction:
val store = store<MyState, MyAction, MyEffect>(scope) { … }
store.start() // fires onEnter for the initial state immediately
Calling start() more than once is a safe no-op.
Effects are missed by a late subscriber¶
Effects are emitted on a SharedFlow with replay = 0 — they are one-shot and not
re-delivered to late subscribers. A subscriber that attaches after an effect was emitted will
never receive it.
In Compose, use handleEffects { } from monaka-compose. It buffers every effect into an
internal Channel from the moment the composable appears, and drains that channel only when the
lifecycle is STARTED. Effects emitted during configuration changes are buffered and delivered
when the screen comes back to the foreground.
In non-Compose code, set up your effect collector before dispatching the first action, or
before calling store.start().
The stub generator skips a file that already exists¶
By default generateMonakaStubs does not overwrite existing files (replace = false). This is
intentional: the generated stubs are a starting point, and you own them once generated.
To overwrite, pass --replace=true on the command line or set it in the extension:
My state machine handler is never called¶
Check the following in order:
-
Is a
state<T>block registered for the current state?
The runtime does an exactstate::classmatch first, then a BFS supertype walk over registered state classes. If no registered class matches the current state (including via supertype), the action is rejected. -
Is the action type registered under the correct
state<T>block?
on<ActionType>handlers are scoped to thestate<T>block they are declared in. An action dispatched while inLoginState.Authenticatedwill not match anon<>instate<LoginState.Idle>. -
Did a parent block already handle the action?
If you have bothstate<LoginState>andstate<LoginState.Loading>registered, andLoginStatehandles the action first (because it was registered before the leaf state), the leaf handler is never reached. Register leaf states before parent catch-all blocks. -
Did a previous handler call
reject()?
reject()is terminal — the action is dropped and no further processing occurs.
Enable LoggingPlugin to trace what the runtime is actually doing:
dispatch() inside a handler doesn't process immediately¶
dispatch() enqueues the action at the back of the Channel. Because the processing coroutine
handles one trigger at a time, the dispatched action is processed after the current handler
finishes. This is by design — it prevents re-entrant state mutations and keeps transitions
deterministic.
If you need the result of the dispatched action before the handler returns, restructure the handler to perform the work inline (the blocking pattern) or split it across two handlers using the fire-and-dispatch pattern.
Multiple transition() calls — only the first takes effect¶
transition() is first-write-wins. Calling it a second time in the same handler is a silent
no-op. This is intentional — it enables fallback patterns:
on<Refresh> {
if (state.isStale) transition(Refreshing)
transition(Active) // no-op if already called above
}
If you want mutually exclusive branches, use if/else so only one transition() call is
reachable.
onUpdate fires when I don't expect it to¶
onUpdate fires when the state type stays the same but the value changes — this
includes any field update on a data class, even one you don't care about.
Use the fromState / toState accessors to guard against unwanted reactions:
state<SearchState> {
onUpdate {
if (fromState.query == toState.query) return@onUpdate // only care about query changes
task { analytics.track(toState.query) }
}
}
StoreRegistry throws on duplicate registration¶
Registering the same store instance (same id) twice throws IllegalArgumentException. This
usually happens when a ViewModel is recreated and tries to re-register a store that was not
unregistered from the previous instance.
Use register — it attaches an invokeOnCompletion callback that automatically unregisters
the store when the owning scope is cancelled, so no manual cleanup is needed when the ViewModel
is cleared:
If you stop a store explicitly before its scope is cancelled, unregister it manually:
testStore fails with "no initial state"¶
testStore requires a StateMachine<S, A, E> built with stateMachine { } that has
initialState(…) set. If you omit initialState and don't call given(state) in the test
case, the store has no starting state and the test fails at startup.
Either set initialState in the machine config:
val machine = stateMachine<MyState, MyAction, MyEffect> {
initialState(MyState.Idle) // ← required
…
}
Or call given(state) at the start of each test case that needs a specific starting point: