Skip to content

How to Resolve Callback Hell in Jetpack Compose with State Hoisting

The Problem

I was building a profile screen in Jetpack Compose. The screen had multiple nested components - a header, a form, some action buttons, and a settings panel. Each component needed to trigger updates that affected other parts of the screen.

My first instinct was to pass the ViewModel directly to every composable. That worked, but I noticed something troubling. My composables were tightly coupled to the ViewModel. I couldn’t reuse them in other screens. Testing became harder because I had to mock ViewModels everywhere.

Then I tried another approach: passing callbacks down through each level. But as the screen grew, my code turned into callback hell. I had callbacks three and four levels deep. The parameter lists got longer and longer. Debugging state changes became a nightmare because I couldn’t track where the state was actually being modified.

BadExample.kt
// This is what I tried first - passing ViewModel everywhere
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
ProfileHeader(viewModel = viewModel) // Bad: tight coupling
ProfileForm(viewModel = viewModel) // Bad: can't reuse
ProfileSettings(viewModel = viewModel) // Bad: hard to test
}

I needed a better approach. That’s when I discovered state hoisting.

My Environment

  • Jetpack Compose (latest stable version)
  • Kotlin 1.9+
  • Android Studio Hedgehog
  • ViewModel with StateFlow

What I Learned

State hoisting is a pattern where you move state to the caller of a composable. The key idea is simple: state flows down, events flow up.

The root composable is the only one that knows about the ViewModel. It collects state from the ViewModel and passes data down to children. When children need to trigger actions, they call callbacks that bubble back up to the root, which then calls ViewModel functions.

Here’s what this looks like in practice:

Data Flow Diagram
ViewModel (single source of truth)
|
v
Root Composable (collects state)
|
v (passes data down)
Child Composables (stateless)
|
v (events bubble up through callbacks)
Root Composable (calls ViewModel)
|
v
ViewModel (updates state)

The Solution

Step 1: Define Your UI State as a Data Class

I created a single immutable data class that represents all the state my screen needs:

UserProfileState.kt
data class UserProfileState(
val user: User?,
val isLoading: Boolean = false,
val error: String? = null,
val isEditing: Boolean = false
)

This becomes my single source of truth. No more state scattered across multiple places.

Step 2: Create a Root Composable

The root composable is the only place that knows about the ViewModel:

ProfileScreen.kt
@Composable
fun ProfileScreen(
viewModel: ProfileViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
ProfileContent(
user = state.user,
isLoading = state.isLoading,
error = state.error,
isEditing = state.isEditing,
onUpdateProfile = { name, email ->
viewModel.updateProfile(name, email)
},
onToggleEdit = { viewModel.toggleEditing() }
)
}

Notice how ProfileContent doesn’t know about the ViewModel. It only receives data and callbacks.

Step 3: Make Child Composables Stateless

Child composables receive only the data they need and callbacks for events:

ProfileContent.kt
@Composable
fun ProfileContent(
user: User?,
isLoading: Boolean,
error: String?,
isEditing: Boolean,
onUpdateProfile: (String, String) -> Unit,
onToggleEdit: () -> Unit
) {
Column {
if (isLoading) {
CircularProgressIndicator()
}
user?.let {
if (isEditing) {
EditProfileForm(
user = it,
onSave = onUpdateProfile,
onCancel = onToggleEdit
)
} else {
ProfileDisplay(
user = it,
onEditClick = onToggleEdit
)
}
}
error?.let { ErrorMessage(it) }
}
}

Step 4: Deep Nesting Without Callback Hell

Even deeply nested composables stay clean:

EditProfileForm.kt
@Composable
fun EditProfileForm(
user: User,
onSave: (String, String) -> Unit,
onCancel: () -> Unit
) {
var name by remember { mutableStateOf(user.name) }
var email by remember { mutableStateOf(user.email) }
Column {
TextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
TextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") }
)
Row {
Button(onClick = { onSave(name, email) }) {
Text("Save")
}
Button(onClick = onCancel) {
Text("Cancel")
}
}
}
}

The EditProfileForm has no idea that a ViewModel exists. It just calls onSave and onCancel. This makes it reusable in any context.

Why This Pattern Matters

After applying state hoisting, I noticed several improvements:

Testability: Stateless composables are easy to test. I can pass any data and callbacks to verify behavior without mocking ViewModels.

Predictability: State changes are centralized in the ViewModel. I always know where to look when debugging.

Reusability: My composables work in different contexts. EditProfileForm could be used for creating new users, not just editing existing ones.

No Callback Hell: Each composable only knows about its immediate callbacks. Events bubble up cleanly.

The Trade-off

Large screens can result in root composables with many callback parameters. I’ve seen screens with 10+ callbacks:

TooManyCallbacks.kt
@Composable
fun ProfileContent(
user: User?,
isLoading: Boolean,
error: String?,
isEditing: Boolean,
onUpdateProfile: (String, String) -> Unit,
onToggleEdit: () -> Unit,
onDeleteAccount: () -> Unit,
onChangePassword: () -> Unit,
onLogout: () -> Unit,
onRefresh: () -> Unit,
// ... more callbacks
) { }

When this happens, I group related callbacks into a data class:

ProfileCallbacks.kt
data class ProfileCallbacks(
val onUpdateProfile: (String, String) -> Unit,
val onToggleEdit: () -> Unit,
val onLogout: () -> Unit,
val onRefresh: () -> Unit
)
@Composable
fun ProfileContent(
user: User?,
isLoading: Boolean,
callbacks: ProfileCallbacks
) {
// Cleaner signature
}

This is also a sign that the screen might be doing too much and should be split into smaller screens.

Common Mistakes I Made

Passing ViewModel to child composables: This creates tight coupling. Don’t do it. Only the root composable should know about the ViewModel.

Not hoisting shared state: If multiple composables need to react to the same state, it should be hoisted to the common ancestor.

Over-hoisting: Not all state needs to be hoisted. Animation values, scroll positions for individual lists, and temporary UI state can stay local.

Creating duplicate state: I once had state in both the ViewModel and local composables. This caused sync issues. The ViewModel should be the single source of truth.

Summary

In this post, I showed how to resolve callback hell in Jetpack Compose using state hoisting. The pattern is simple: state flows down from a single source of truth, events flow up through callbacks. This eliminates deep callback chains while keeping composables testable and reusable. The key insight is that only the root composable should know about the ViewModel - everything else receives data and callbacks, making it clean and predictable.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments