A production-grade KSP (Kotlin Symbol Processing) library that eliminates callback hell in Jetpack Compose by generating type-safe action dispatchers from a simple annotated interface.
As Compose screens grow, best practices require hoisting all callbacks up to the screen's entry point. This quickly creates massive, unreadable signatures:
// ❌ Before — painful to maintain
@Composable
fun LoginScreen(
email: String,
password: String,
isLoading: Boolean,
onEmailChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClicked: () -> Unit,
onForgotPasswordClicked: () -> Unit,
onDismissError: () -> Unit,
onTogglePasswordVisibility: () -> Unit
) { }
// Every @Preview needs all of these filled:
@Preview
@Composable
fun LoginPreview() {
LoginScreen(
email = "",
password = "",
isLoading = false,
onEmailChanged = {},
onPasswordChanged = {},
onLoginClicked = {},
onForgotPasswordClicked = {},
onDismissError = {},
onTogglePasswordVisibility = {}
)
}Adding one new button means updating:
- The Composable signature
- The ViewModel call site
- Every single
@Preview
Annotate an interface. KSP generates everything else.
// ✅ After — you write only this
@ComposeActions
interface LoginActions {
fun onEmailChanged(email: String)
fun onPasswordChanged(password: String)
fun onLoginClicked()
fun onForgotPasswordClicked()
fun onDismissError()
fun onTogglePasswordVisibility()
}KSP generates three artifacts automatically:
// 1. Data class dispatcher
data class LoginActionsDispatcher(
val onEmailChanged: (String) -> Unit,
val onPasswordChanged: (String) -> Unit,
val onLoginClicked: () -> Unit,
val onForgotPasswordClicked: () -> Unit,
val onDismissError: () -> Unit,
val onTogglePasswordVisibility: () -> Unit
) {
// 2. Preview stub — zero boilerplate in @Preview
companion object {
val Preview: LoginActionsDispatcher
get() = LoginActionsDispatcher(
onEmailChanged = { _ -> },
onPasswordChanged = { _ -> },
onLoginClicked = {},
onForgotPasswordClicked = {},
onDismissError = {},
onTogglePasswordVisibility = {}
)
}
}
// 3. remember() helper — stable across recompositions
@Composable
fun rememberLoginActions(
onEmailChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClicked: () -> Unit,
onForgotPasswordClicked: () -> Unit,
onDismissError: () -> Unit,
onTogglePasswordVisibility: () -> Unit
): LoginActionsDispatcher = remember() {
LoginActionsDispatcher()
}Now your screen signature becomes clean and maintainable:
// ✅ ONE parameter instead of N callbacks
@Composable
fun LoginScreen(
state: LoginState,
actions: LoginActionsDispatcher
) { }
// ✅ Preview is trivial
@Preview
@Composable
fun LoginPreview() {
LoginScreen(
state = LoginState.Initial,
actions = LoginActionsDispatcher.Preview // done.
)
}┌─────────────────────────────────────────────────────────────┐
│ Kotlin Compiler │
│ │
│ Your .kt files ──► KSP Plugin ──► Your Processor runs │
│ │ │
│ ▼ │
│ ComposeActionsProvider.create() │
│ (called once) │
│ │ │
│ ▼ │
│ ComposeActionsProcessor.process() │
│ (called each round) │
│ │ │
│ getSymbolsWithAnnotation(...) │
│ filterIsInstance<KSClassDeclaration>() │
│ partition { it.validate() } │
│ │ │
│ ▼ │
│ ComposeActionsVisitor.visitClassDeclaration() │
│ (called per annotated interface) │
│ │ │
│ Extract functions + parameters │
│ Build FileSpec with KotlinPoet │
│ Write to build/generated/ksp/ │
└─────────────────────────────────────────────────────────────┘
| Class | Role | Called |
|---|---|---|
ComposeActionsProvider |
Factory — creates the Processor | Once per compilation |
ComposeActionsProcessor |
Orchestrator — finds symbols, validates, guards | Once per KSP round |
ComposeActionsVisitor |
Worker — extracts data, generates code | Once per annotated interface |
@ComposeActions(
suffix = "Dispatcher", // generated class name = InterfaceName + suffix
generatePreview = true, // generate Companion.Preview no-op stub
generateRemember = true // generate rememberXxx() Composable helper
)
interface MyActions {
fun onSomething()
fun onSomethingElse(value: String)
}| Parameter | Type | Default | Description |
|---|---|---|---|
suffix |
String |
"Dispatcher" |
Appended to interface name for the generated class |
generatePreview |
Boolean |
true |
Generate Companion.Preview no-op stub |
generateRemember |
Boolean |
true |
Generate rememberXxx() Composable helper |
The processor enforces these rules at compile time — you get a clear error message pointing to the exact file and line if any rule is violated.
| Rule | Error Message |
|---|---|
Must be an interface |
@ComposeActions must be applied to an interface. Found CLASS 'LoginActions' |
| No properties allowed | @ComposeActions interface must only contain functions. Found properties: [name] |
All functions must return Unit |
Function 'getUser' must return Unit. Action functions are fire-and-forget |
| Must have at least one function | Warning: has no functions — nothing to generate |
// Only rules: interface, Unit return, no properties
@ComposeActions
interface EditProfileActions {
fun onNameChanged(name: String)
fun onEmailChanged(email: String)
fun onAvatarClicked()
fun onSaveClicked()
fun onCancelClicked()
}@Composable
fun EditProfileScreen(
state: EditProfileState,
actions: EditProfileActionsDispatcher // generated
) {
TextField(
value = state.name,
onValueChange = actions.onNameChanged // direct reference — no wrapping lambda
)
Button(onClick = actions.onSaveClicked) {
Text("Save")
}
}@Preview(showBackground = true)
@Composable
fun EditProfilePreview() {
EditProfileScreen(
state = EditProfileState(name = "John Doe", email = "john@example.com"),
actions = EditProfileActionsDispatcher.Preview // generated — no boilerplate
)
}After running ./gradlew :app:build, find generated files at:
app/build/generated/ksp/main/kotlin/
└── tn.studio.kspdemoapp.presentation/
└── LoginActionsDispatcher.kt
These files are automatically included in compilation — you never need to reference them manually. Never edit them — they are overwritten on every build.