Skip to content

Login

Source: sample/shared/…/examples/login/

The login example is a multi-state machine demonstrating the onEnter blocking pattern, the onError recovery hook, and generated transition helpers from @Transition and @SelfTransition. It also shows how to register a catch-all handler on the parent sealed interface for actions that are valid from any substate.


Types

@SelfTransition
sealed interface LoginState : State {
    data object Idle : LoginState

    @Transition(Submitting::class)
    data class Typing(val username: String, val password: String) : LoginState {
        val isValid get() = username.isNotBlank() && password.isNotBlank()
    }

    @Transition(Authenticated::class, Error::class)
    data class Submitting(val username: String, val password: String) : LoginState

    data class Authenticated(val username: String) : LoginState

    data class Error(val message: String, val username: String, val password: String) : LoginState
}

sealed interface LoginAction : Action {
    data class UpdateCredentials(val username: String, val password: String) : LoginAction
    data object Submit : LoginAction
    data object Retry  : LoginAction
    data object Logout : LoginAction
}

sealed interface LoginEffect : Effect {
    data object NavigateToHome  : LoginEffect
    data object NavigateToLogin : LoginEffect
    data class  ShowValidationError(val message: String) : LoginEffect
}

State machine

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

    initialState(LoginState.Idle)

    state<LoginState.Idle> {
        on<LoginAction.UpdateCredentials> {
            transition(state.toTyping(username = action.username, password = action.password))
        }
    }

    state<LoginState.Typing> {
        on<LoginAction.UpdateCredentials> {
            transition(state.copy(username = action.username, password = action.password))
        }
        on<LoginAction.Submit> {
            if (!state.isValid) {
                sideEffect(LoginEffect.ShowValidationError("Please fill in all fields."))
            } else {
                transition(state.toSubmitting())
            }
        }
    }

    state<LoginState.Submitting> {
        onEnter {
            // Blocking pattern: the action queue pauses until this returns.
            val username = loginRepository.login(state.username, state.password)
            transition(state.toAuthenticated(username = username))
            sideEffect(LoginEffect.NavigateToHome)
        }
        onError {
            transition(state.toError(message = error.message ?: "Login failed"))
        }
    }

    state<LoginState.Error> {
        on<LoginAction.UpdateCredentials> {
            transition(state.copy(username = action.username, password = action.password))
        }
        on<LoginAction.Retry> {
            transition(state.toSubmitting())
        }
    }

    // Logout is valid from *any* LoginState subtype.
    state<LoginState> {
        on<LoginAction.Logout> {
            transition(LoginState.Idle)
            sideEffect(LoginEffect.NavigateToLogin)
        }
    }

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

Patterns demonstrated

onEnter blocking pattern

Submitting.onEnter calls the repository directly as a suspend function. The action queue is paused while the coroutine suspends — nothing else is processed until login() returns. This is the simplest pattern when only one request can be in-flight at a time:

state<LoginState.Submitting> {
    onEnter {
        val username = loginRepository.login(state.username, state.password)
        transition(state.toAuthenticated(username = username))
        sideEffect(LoginEffect.NavigateToHome)
    }
}

For non-blocking alternatives, see Handlers — Fire-and-dispatch.

onError recovery

If loginRepository.login() throws, the onError block catches it and transitions to the Error state. Without onError, the exception would be forwarded to plugins and the state would remain Submitting indefinitely:

state<LoginState.Submitting> {
    onError {
        transition(state.toError(message = error.message ?: "Login failed"))
    }
}

See Error handling for full details.

Generated transition helpers

@Transition on Typing and Submitting causes the KSP processor to generate typed toXxx() functions. Shared properties (same name and type on both source and target) become defaulted parameters:

// Generated from @Transition(Submitting::class) on Typing:
fun LoginState.Typing.toSubmitting(): LoginState.Submitting =
    LoginState.Submitting(username = this.username, password = this.password)

// Generated from @Transition(Authenticated::class, Error::class) on Submitting:
fun LoginState.Submitting.toAuthenticated(username: String): LoginState.Authenticated = 
fun LoginState.Submitting.toError(message: String): LoginState.Error = 

These make handlers concise and refactor-safe — renaming a constructor parameter updates all generated call sites.

Catch-all parent block for Logout

Logout is valid from every substate. Registering it on state<LoginState> (the sealed root) means the handler fires regardless of which subtype the machine is currently in, without duplicating the handler in every leaf:

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

Leaf blocks still take priority: if LoginState.Typing registered its own on<Logout>, that would win while in Typing.