DEV Community

RockAndNull
RockAndNull

Posted on • Originally published at paleblueapps.com on

Navigate back with results in Jetpack Compose Navigation

Navigate back with results in Jetpack Compose Navigation

If you’ve ever needed to open a screen, let the user pick something, and then pass that selection back to the previous screen, you probably reached for shared ViewModels, singletons, or brittle route parameters.

There’s a simpler way that works with Jetpack Compose Navigation: navigate forward → wait for a result → navigate back with that result.

This post introduces a tiny helper you can drop into your project and explains how it works, complete with examples, pitfalls, and testing tips.

The Helper

Put this file somewhere in your project as-is.

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.NavController
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.serialization.json.Json

private const val RESULT_KEY = "result"

fun <T> NavController.navigateBackWithResult(value: T) {
    previousBackStackEntry?.savedStateHandle?.set(RESULT_KEY, value)
    navigateUp()
}

inline fun <reified T> NavController.navigateBackWithSerializableResult(value: T) {
    navigateBackWithResult(Json.encodeToString(value))
}

suspend fun <T> NavController.navigateForResult(route: Any): T? =
    suspendCancellableCoroutine { continuation ->
        val currentNavEntry = currentBackStackEntry
            ?: throw IllegalStateException("No current back stack entry found")

        navigate(route)

        val lifecycleObserver = object : LifecycleEventObserver {
            override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
                if (event == Lifecycle.Event.ON_START) {
                    continuation.resume(currentNavEntry.savedStateHandle[RESULT_KEY])
                    currentNavEntry.savedStateHandle.remove<T>(RESULT_KEY)
                    currentNavEntry.lifecycle.removeObserver(this)
                }
            }
        }

        currentNavEntry.lifecycle.addObserver(lifecycleObserver)

        continuation.invokeOnCancellation {
            currentNavEntry.savedStateHandle.remove<T>(RESULT_KEY)
            currentNavEntry.lifecycle.removeObserver(lifecycleObserver)
        }
    }

suspend inline fun <reified T> NavController.navigateForSerializableResult(route: Any): T? {
    val result: String = navigateForResult(route) ?: return null
    return Json.decodeFromString(result)
}

Enter fullscreen mode Exit fullscreen mode

NavigateBackWithResult.kt

How it works

  1. Caller launches a route with navigateForResult(route). The current entry is remembered, navigation occurs, and a lifecycle observer is set up.
  2. Callee finishes by calling navigateBackWithResult(value). The result is stored in the caller’s SavedStateHandle under a fixed key.
  3. Caller resumes on ON_START. The coroutine unblocks, retrieves the result, and clears the key to avoid stale values.

This is lifecycle-aware, type-safe, and minimal - no ActivityResultContract, no shared ViewModel, no event bus.

How to use it

Here's a simple example to see the pattern in action. Imagine a screen where the user picks a color, and when they go back, the chosen value is passed to the previous screen. This is the most straightforward case: a basic type (String) being sent back.

// Routes:

object Routes {
    const val HOME = "home"
    const val COLOR_PICKER = "color-picker"
}

// Nav host:

@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController, startDestination = Routes.HOME) {
        composable(Routes.HOME) { HomeScreen(navController) }
        composable(Routes.COLOR_PICKER) { ColorPickerScreen(navController) }
    }
}

// Caller (HomeScreen):

@Composable
fun HomeScreen(navController: NavController) {
    val scope = rememberCoroutineScope()
    var selectedColor by remember { mutableStateOf<String?>(null) }

    Column(Modifier.padding(16.dp)) {
        Text("Selected: ${'$'}{selectedColor ?: "none"}")
        Button(onClick = {
            scope.launch {
                val result: String? = navController.navigateForResult(Routes.COLOR_PICKER)
                selectedColor = result
            }
        }) {
            Text("Pick color")
        }
    }
}

// Callee (ColorPickerScreen):

@Composable
fun ColorPickerScreen(navController: NavController) {
    val colors = listOf("Red", "Green", "Blue")
    Column(Modifier.padding(16.dp)) {
        colors.forEach { color ->
            Button(onClick = { navController.navigateBackWithResult(color) }) {
                Text(color)
            }
        }
        OutlinedButton(onClick = { navController.navigateUp() }) {
            Text("Cancel")
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Edge cases and gotchas

There are a few edge cases and nuances worth keeping in mind. Results are always nullable (T?), so you should be prepared to handle the case where the user cancels or navigates back without setting anything. If you use the non-serializable variant and the caller and callee don’t agree on the type, you’ll hit a ClassCastException, so type alignment is critical. Results also don’t survive process death, since this mechanism is essentially a callback rather than persistent state. Finally, avoid waiting for multiple results concurrently from the same destination; if you need that, wrap access in your own guard or queue to prevent conflicts.

Conclusion

This helper gives you a lightweight, lifecycle-aware pattern for passing data back through the Compose Navigation stack: you simply drop it into your project, call navigateForResult() when moving forward, and then call navigateBackWithResult() when returning. That’s all it takes - no extra contracts and no brittle hacks.

Happy coding!

Top comments (0)