A good feature contract makes legal events and states visible before reading its ViewModel.
data class ProfileState(
val profile: Profile? = null,
val saving: Boolean = false,
val fieldErrors: Map<String, String> = emptyMap(),
)
sealed interface ProfileIntent {
data object Load : ProfileIntent
data class NameChanged(val value: String) : ProfileIntent
data object SaveTapped : ProfileIntent
}
sealed interface ProfileEffect {
data object Saved : ProfileEffect
data class OpenUrl(val url: String) : ProfileEffect
}
State must survive recomposition and often process recreation. Effects represent consumable instructions. A snackbar stored indefinitely in state can reappear after rotation; a validation message should remain until corrected.
Name intents after events, not implementation setters. SaveTapped gives the ViewModel room to validate, persist, and emit a result. SetLoading exposes internal mechanics to the UI.
Sealed interfaces make exhaustive when expressions possible and keep payloads typed. Avoid a generic OnClick(id: String) that pushes parsing back into the ViewModel.
Contracts are most valuable as boundaries. The composable sends intents and renders state; it never needs to know which repository or dispatcher produces the result.