Skip to content

StateMachineStore & reusable configs

Monaka provides two patterns for structuring larger machines: StateMachineStore (a named class with an inline DSL) and stateMachine { } (a reusable configuration value). Both produce the same runtime behaviour — the choice is about code organisation.


store { } — inline anonymous machine

The simplest form. The machine lives entirely inside the store call, typically inside a ViewModel or composable:

val store = store<CounterState, CounterAction, CounterEffect>(viewModelScope) {
    initialState(CounterState(0))
    state<CounterState> {
        on<CounterAction.Increment> { transition(state.copy(count = state.count + 1)) }
    }
}

Good for small, self-contained machines. Not easily testable with testStore without extracting the handlers — see Testing for why.


stateMachine { } — reusable configuration

stateMachine { } builds an immutable StateMachine snapshot without starting it. Separate the configuration from execution to:

  • Share the same handlers across multiple store instances
  • Inject different initial states at start time
  • Pass directly to testStore in tests
val loginMachineConfig = stateMachine<LoginState, LoginAction, LoginEffect> {
    initialState(LoginState.Idle)
    state<LoginState.Idle> {  }
    state<LoginState.Typing> {  }
}

// Start two independent instances with the same config:
val store1 = store(loginMachineConfig, scope1)
val store2 = store(loginMachineConfig, scope2, initialState = LoginState.Typing("bob"))

Runtime overrides

Both store { } and store(config, scope) accept initialState and plugins overrides that apply at runtime, after the builder block has run:

val store = store(
    stateMachine = loginMachineConfig,
    scope = viewModelScope,
    initialState = savedStateHandle.get<LoginState>("state"),  // replaces initialState(…) in config
    plugins = listOf(analyticsPlugin),                          // appended after config's plugins
)

extraBufferCapacity (default: DEFAULT_BUFFER_CAPACITY = 64) controls the SharedFlow buffer for effects and actions. Increase it if your machine emits effects in rapid bursts:

val store = store(
    stateMachine = loginMachineConfig,
    scope = viewModelScope,
    extraBufferCapacity = 128,
)

Async state restoration

When persisted state lives in an async source (e.g. Jetpack DataStore, a SQLite database, or encrypted preferences), pass an initializer suspend lambda. It runs inside the processing coroutine — before onEnter fires and before any queued actions are processed — so the restored state is always the first state the machine acts on:

val store = store(
    stateMachine = loginMachineConfig,
    scope = viewModelScope,
    initializer = { dataStore.data.first().toLoginState() },
) {
    initialState(LoginState.Idle)   // used only if initializer is null or throws
}

For synchronous sources (SavedStateHandle, UserDefaults) the existing initialState parameter is simpler and sufficient:

val store = store(
    stateMachine = loginMachineConfig,
    scope = viewModelScope,
    initialState = savedStateHandle.get<LoginState>("state"),
)

Ordering guarantee

Actions dispatched before the initializer completes are queued in the processing channel and run after onEnter has fired with the restored state. No actions are dropped and no action sees the un-restored placeholder state.

Error handling

If the initializer throws, Plugin.onError is called with HandlerType.Restore and the store falls back to its configured initialState. onEnter always fires so the machine always reaches a usable state.


StateMachine delegation — named class with injected dependencies

When a machine needs constructor-injected dependencies (repositories, use-cases), declare it as a named class that implements StateMachine via delegation to stateMachine { }. The handlers close over the constructor parameters; the class itself is just an immutable config snapshot:

class LoginStateMachine(
    loginRepository: LoginRepository,
) : StateMachine<LoginState, LoginAction, LoginEffect> by stateMachine(builder = {

    initialState(LoginState.Idle)

    state<LoginState.Typing> {
        on<LoginAction.Submit> {
            task("login", autoCancel = true) {
                when (val result = loginRepository.login(state.username, state.password)) {
                    is Success -> dispatch(LoginAction.LoginSucceeded(result.user))
                    is Failure -> dispatch(LoginAction.LoginFailed(result.message))
                }
            }
            transition(LoginState.Submitting)
        }
    }

    state<LoginState.Submitting> {
        on<LoginAction.LoginSucceeded> {
            transition(LoginState.Authenticated(action.user))
            sideEffect(LoginEffect.NavigateToHome)
        }
        on<LoginAction.LoginFailed> {
            transition(LoginState.Idle)
            sideEffect(LoginEffect.ShowError(action.message))
        }
    }

    state<LoginState> {
        on<LoginAction.Logout> {
            transition(LoginState.Idle)
            sideEffect(LoginEffect.NavigateToLogin)
        }
    }

    install(LoggingPlugin(tag = "Login"))
})

Start the machine from a ViewModel using store(config, scope) or StateMachineStore:

class LoginViewModel(loginRepository: LoginRepository) : ViewModel() {
    private val machine = LoginStateMachine(loginRepository)

    // Option A — store factory
    val store: Store<LoginState, LoginAction, LoginEffect> = store(machine, viewModelScope)

    // Option B — StateMachineStore wrapper (also exposes `store.stateMachine`)
    val store = StateMachineStore(machine, viewModelScope)
}

Because LoginStateMachine implements StateMachine, pass it directly to testStore:

@Test
fun loginFlow() = testStore(machine = LoginStateMachine(fakeRepository)) {  }

Choosing a pattern

Pattern Use when
store { } inline Small machine, no testing needed, or machine lives entirely inside a ViewModel.
stateMachine { } + store(config, scope) You want a reusable config value — multiple instances, passing to testStore, or separating config from startup.
Named class : StateMachine<> by stateMachine { } The machine has injected dependencies and you want a named, typed class that is also directly passable to testStore.

All three patterns produce a Store<S, A, E> — the rest of the API (state, effects, dispatch, plugins, relays) is identical regardless of which pattern you use.