Passing Arguments and Data Between Destinations in Android Using Jetpack Compose
Course: Android App Development
Topic: Passing Arguments and Data Between Destinations
Course Link: Android App Development Course
Demo Registration: Register for a Course Demo
JustAcademy Homepage: Visit JustAcademy
1. Introduction
Modern Android applications contain multiple screens, also called destinations. For example, an educational application may contain a Home screen, Course List screen, Course Details screen, and Student Profile screen. These screens often need to share information as users navigate between them.
Passing arguments and data between destinations means sending the information required by one screen to another screen during navigation. In Jetpack Compose, this is commonly implemented using the Navigation Compose library, a NavController, a navigation graph, and route arguments.
For example, when a user selects a course from a list, the application can pass the course ID to the Course Details destination. The details screen then retrieves the course information using that ID.
Official documentation: Design your navigation graph and Pass data between destinations.
2. Learning Objectives
- Understand destinations, routes, and navigation arguments.
- Learn how Navigation Compose transfers data between screens.
- Pass strings, integers, Booleans, and other supported values.
- Retrieve arguments in the destination that receives them.
- Understand required and optional arguments.
- Use type-safe routes with Kotlin Serialization.
- Handle missing, invalid, and optional data safely.
- Understand when to use a ViewModel or repository instead of passing entire objects.
- Build a practical course-list and course-details navigation flow.
3. Understanding Navigation Destinations
A destination represents a screen or another navigable part of an application. A navigation graph describes the destinations and how users can move between them.
Important navigation concepts
| Concept |
Description |
Example |
| Destination |
A screen or navigable location. |
CourseDetailsScreen |
| Route |
Identifies the destination and may contain arguments. |
CourseDetails(courseId = 101) |
| NavController |
Controls navigation between destinations. |
navController.navigate(...) |
| NavHost |
Connects a navigation controller to the navigation graph and displays the active destination. |
Home and Course Details screens |
| Argument |
Data supplied to a destination. |
courseId = 101 |
| Back stack |
The navigation history used for Back navigation. |
Home → Course Details |
4. Setting Up Navigation Compose
Before passing data between destinations, configure Navigation Compose in the Android project. Use a compatible library version that matches the rest of the project's AndroidX dependencies.
Example Gradle dependency:
dependencies {
implementation("androidx.navigation:navigation-compose:YOUR_COMPATIBLE_VERSION")
}
Replace YOUR_COMPATIBLE_VERSION with a compatible published version. The placeholder is not a literal version number. Check the official AndroidX Navigation release information for current versions.
For the type-safe route examples in these notes, also configure the Kotlin Serialization plugin and serialization library according to the official documentation.
5. Creating Destinations with Routes
In Navigation Compose, routes identify the destinations users can visit. Modern Navigation Compose supports type-safe routes defined using serializable Kotlin objects and classes.
A destination without arguments can be represented by an object:
import kotlinx.serialization.Serializable
@Serializable
data object Home
A destination that needs arguments can be represented by a data class:
import kotlinx.serialization.Serializable
@Serializable
data class CourseDetails(
val courseId: Int
)
Here, Home identifies the home destination, while CourseDetails carries a course ID. The destination can use this ID to find the corresponding course information.
6. Creating a Basic Navigation Graph
A NavHost defines the navigation graph and connects route types to their corresponding composable screens.
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Home
) {
composable<Home> {
HomeScreen(
onOpenCourse = {
navController.navigate(
CourseDetails(courseId = 101)
)
}
)
}
composable<CourseDetails> { backStackEntry ->
val route = backStackEntry.toRoute<CourseDetails>()
CourseDetailsScreen(
courseId = route.courseId
)
}
}
}
@Composable
fun HomeScreen(
onOpenCourse: () -> Unit
) {
Button(onClick = onOpenCourse) {
Text("View Course")
}
}
@Composable
fun CourseDetailsScreen(
courseId: Int
) {
Text(text = "Selected Course ID: $courseId")
}
This example illustrates the main navigation flow. The Home route opens the course details destination with ID 101. The destination reads its typed route with toRoute<CourseDetails>() and passes the ID to the screen.
Note: This code uses the type-safe Navigation Compose APIs. Ensure the project's Navigation Compose version supports these APIs and that the Kotlin Serialization plugin and required imports are configured.
7. Passing a String Argument
String arguments are useful for passing short values such as usernames, category names, search terms, or display labels.
Define a route containing a string property:
@Serializable
data class Profile(
val username: String
)
Navigate to the profile destination:
navController.navigate(
Profile(username = "Aarav")
)
Receive the route argument:
composable<Profile> { backStackEntry ->
val profile = backStackEntry.toRoute<Profile>()
ProfileScreen(
username = profile.username
)
}
Display the value in the destination:
@Composable
fun ProfileScreen(username: String) {
Text(text = "Welcome, $username")
}
String arguments should be used for small values that the destination genuinely needs. For larger user profiles, it is usually better to pass a stable identifier and load the current profile data from a repository.
8. Passing an Integer Argument
Integer arguments are commonly used for database IDs, product identifiers, selected tab indexes, or item numbers.
@Serializable
data class ProductDetails(
val productId: Int
)
// Navigate to a product details destination.
navController.navigate(
ProductDetails(productId = 250)
)
Retrieve the value:
composable<ProductDetails> { backStackEntry ->
val route = backStackEntry.toRoute<ProductDetails>()
ProductDetailsScreen(
productId = route.productId
)
}
The receiving screen can use productId to request the product from a ViewModel or repository. The ID itself does not automatically retrieve the product.
9. Passing Boolean Arguments
A Boolean argument can communicate a simple condition, such as whether a user is viewing favorites or whether a screen should initially display a particular mode.
@Serializable
data class Settings(
val notificationsEnabled: Boolean
)
// Navigate with the selected value.
navController.navigate(
Settings(notificationsEnabled = true)
)
Retrieve the value:
composable<Settings> { backStackEntry ->
val route = backStackEntry.toRoute<Settings>()
SettingsScreen(
notificationsEnabled = route.notificationsEnabled
)
}
For settings that change over time or must persist across app launches, the destination should update the appropriate state holder or data source rather than treating a navigation argument as permanent storage.
10. Passing Multiple Arguments
A destination can require several values. A typed route makes these values explicit and groups them into one route object.
@Serializable
data class StudentDetails(
val studentId: Int,
val studentName: String,
val courseName: String
)
Navigate to the destination:
navController.navigate(
StudentDetails(
studentId = 15,
studentName = "Priya",
courseName = "Android Development"
)
)
Retrieve and display the arguments:
composable<StudentDetails> { backStackEntry ->
val student = backStackEntry.toRoute<StudentDetails>()
StudentDetailsScreen(
studentId = student.studentId,
studentName = student.studentName,
courseName = student.courseName
)
}
This approach is suitable for a few small values needed to identify or initialize the destination. Avoid sending large collections or complex mutable application state through route arguments.
11. Passing Optional Arguments
Sometimes a destination can operate without a particular value. Kotlin supports nullable properties and default values, which can represent optional route arguments.
@Serializable
data class SearchResults(
val query: String = "",
val category: String? = null
)
Navigate with both values:
navController.navigate(
SearchResults(
query = "Android",
category = "Development"
)
)
Or use the default values:
navController.navigate(
SearchResults()
)
The receiving destination can check whether the optional value is present:
composable<SearchResults> { backStackEntry ->
val route = backStackEntry.toRoute<SearchResults>()
Text(text = "Search: ${route.query}")
Text(
text = route.category ?: "All categories"
)
}
Default values should represent meaningful fallback behavior. Do not silently substitute a value if doing so could cause the app to display incorrect or misleading information.
12. Passing Data from a List to a Details Screen
A common navigation pattern is to show a list of items and open a details screen when an item is selected. The recommended approach is often to pass the selected item's stable identifier, then load the full data in the destination.
Example course model
data class Course(
val id: Int,
val title: String,
val duration: String
)
val courses = listOf(
Course(101, "Android Development", "8 Weeks"),
Course(102, "Python Programming", "6 Weeks"),
Course(103, "Core Java", "7 Weeks")
)
Define a route for course details
@Serializable
data class CourseDetails(
val courseId: Int
)
Build a reusable course item
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CourseItem(
course: Course,
onClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(text = course.title)
Text(text = "Duration: ${course.duration}")
Button(onClick = onClick) {
Text("View Details")
}
}
}
}
Navigate using the selected course ID
@Composable
fun CourseListScreen(
courses: List<Course>,
onCourseSelected: (Int) -> Unit
) {
Column {
courses.forEach { course ->
CourseItem(
course = course,
onClick = {
onCourseSelected(course.id)
}
)
}
}
}
Connect the callback to the navigation controller from the parent navigation graph:
composable<Home> {
CourseListScreen(
courses = courses,
onCourseSelected = { selectedId ->
navController.navigate(
CourseDetails(courseId = selectedId)
)
}
)
}
For a large collection, use LazyColumn or another appropriate lazy layout rather than composing every item in a regular Column.
13. Retrieving Data in the Details Destination
When a destination receives a course ID, it needs a reliable way to find the corresponding course. A small demonstration can search a sample list. A real application should normally use a ViewModel and repository.
@Composable
fun CourseDetailsRoute(
courseId: Int,
courses: List<Course>
) {
val course = courses.find {
it.id == courseId
}
if (course != null) {
Column {
Text(text = course.title)
Text(text = "Duration: ${course.duration}")
}
} else {
Text(text = "Course not found")
}
}
Connect the route to the screen:
composable<CourseDetails> { backStackEntry ->
val route = backStackEntry.toRoute<CourseDetails>()
CourseDetailsRoute(
courseId = route.courseId,
courses = courses
)
}
This is a simple example using an in-memory list. If the course data comes from a database or API, load it through the appropriate data layer, expose loading and error states, and handle cases where the record no longer exists.
14. Why Passing IDs Is Often Better Than Passing Objects
Passing a full object through navigation may appear convenient, but it can create problems when the object is large, mutable, or difficult to serialize. Android's navigation guidance recommends passing the minimum data needed to identify the content and retrieving the full data from the appropriate source.
| Approach |
Advantages |
Limitations |
| Pass an ID |
Small argument, clear destination requirement, easy to reload current data. |
The destination needs access to a repository or data source. |
| Pass a few simple values |
Convenient for small pieces of information needed immediately. |
Values may become stale if the underlying record changes. |
| Pass a large object |
May avoid an immediate lookup in a small demonstration. |
Can increase saved-state size, complicate serialization, and create consistency problems. |
| Share a ViewModel or repository |
Useful for shared UI state or retrieving complex data. |
Requires deliberate lifecycle and ownership design. |
For example, pass courseId = 101 rather than an entire course catalog. The destination can retrieve the current course using that identifier.
15. Using a ViewModel to Load Destination Data
A ViewModel can manage UI-related state and coordinate data loading for a destination. The route argument identifies the requested record, while the ViewModel and repository handle retrieving the record and representing its state.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed interface CourseUiState {
data object Loading : CourseUiState
data class Success(val course: Course) : CourseUiState
data object NotFound : CourseUiState
data class Error(val message: String) : CourseUiState
}
interface CourseRepository {
suspend fun getCourseById(id: Int): Course?
}
class CourseDetailsViewModel(
private val repository: CourseRepository
) : ViewModel() {
private val _uiState =
MutableStateFlow<CourseUiState>(CourseUiState.Loading)
val uiState: StateFlow<CourseUiState> =
_uiState.asStateFlow()
fun loadCourse(courseId: Int) {
viewModelScope.launch {
_uiState.value = CourseUiState.Loading
try {
val course = repository.getCourseById(courseId)
_uiState.value = if (course != null) {
CourseUiState.Success(course)
} else {
CourseUiState.NotFound
}
} catch (exception: Exception) {
_uiState.value = CourseUiState.Error(
exception.message ?: "Unable to load course"
)
}
}
}
}
This is an architectural example. A production repository should handle expected data-source failures carefully and avoid converting cancellation into an ordinary error. A dependency injection framework or ViewModel factory can provide the repository to the ViewModel.
The destination should observe the ViewModel's UI state and render the appropriate loading, success, empty, or error interface. Keep navigation arguments small and use them to initiate the data-loading process.
16. Passing Data Back to a Previous Destination
Sometimes a screen must return a result to the previous screen. For example, a selection screen may allow a user to choose a category and then return that selection to the search screen.
One Navigation Compose pattern is to use the previous destination's SavedStateHandle through its back-stack entry. Set the result before navigating back, then observe it in the previous destination.
Setting a result
// Run from the destination where the user makes a selection.
navController.previousBackStackEntry
?.savedStateHandle
?.set("selected_category", "Development")
navController.popBackStack()
Observing a result
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation.NavController
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
@Composable
fun ObserveCategoryResult(
navController: NavController
) {
var selectedCategory by remember {
mutableStateOf<String?>(null)
}
LaunchedEffect(navController) {
val savedStateHandle =
navController.currentBackStackEntry
?.savedStateHandle
selectedCategory = savedStateHandle
?.getStateFlow<String?>(
"selected_category",
null
)
?.filterNotNull()
?.first()
savedStateHandle?.set(
"selected_category",
null
)
}
Text(
text = selectedCategory ?: "No category selected"
)
}
Important: Result handling must account for the back-stack lifecycle. In more complex applications, collect from the correct previous entry when it becomes active again, and clear consumed results to prevent accidental reuse. A shared ViewModel can be a better fit when several destinations need to coordinate ongoing state.
17. Using Type-Safe Navigation Routes
Type-safe routes reduce reliance on manually constructed route strings and help developers work with explicit argument types. In supported Navigation Compose versions, serializable route types can be used with composable<T>, navigate(), and toRoute<T>().
import kotlinx.serialization.Serializable
@Serializable
data object Dashboard
@Serializable
data class UserDetails(
val userId: Int,
val showContactInfo: Boolean = false
)
Navigate with the required argument:
navController.navigate(
UserDetails(
userId = 42,
showContactInfo = true
)
)
Retrieve it at the destination:
composable<UserDetails> { backStackEntry ->
val route = backStackEntry.toRoute<UserDetails>()
UserDetailsScreen(
userId = route.userId,
showContactInfo = route.showContactInfo
)
}
Type-safe routes make the expected argument structure explicit. They do not replace validation of business rules, checking whether a record exists, or authorization to access the requested data.
18. Working with Legacy String Routes
Some projects use string-based routes, such as "course_details/{courseId}". This approach is common in older Navigation Compose examples and can still be used with supported APIs. It requires developers to manage route construction and argument parsing carefully.
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(
onOpenCourse = { courseId ->
navController.navigate(
"course_details/$courseId"
)
}
)
}
composable(
route = "course_details/{courseId}",
arguments = listOf(
navArgument("courseId") {
type = NavType.IntType
}
)
) { backStackEntry ->
val courseId = backStackEntry
.arguments
?.getInt("courseId")
if (courseId != null) {
CourseDetailsScreen(
courseId = courseId
)
}
}
}
For this example, import androidx.navigation.NavType, androidx.navigation.navArgument, and the relevant Navigation Compose functions. The destination declares the expected integer argument, while the source constructs a route with the selected ID.
When passing text containing spaces or special characters in string-based routes, encode the value correctly and decode it as required. Do not build complex routes by concatenating unescaped user-provided text. Prefer type-safe routes when they fit the project's Navigation Compose version and setup.
19. Validating Arguments and Handling Errors
Navigation arguments should be validated before they are used. Even when an argument has the correct Kotlin type, it may refer to a record that no longer exists or that the user is not permitted to access.
- Check that the requested record exists.
- Handle invalid IDs and missing records.
- Provide a useful empty or error state.
- Do not trust an ID as proof that the user is authorized to access a record.
- Handle network failures and loading states when data comes from a remote source.
- Use nullable values only when the destination supports the absence of that data.
- Avoid silently replacing invalid arguments with unrelated default values.
20. Common Errors and Troubleshooting
| Problem |
Possible Cause |
Solution |
| Destination cannot retrieve an argument |
Argument was not included in the route or was read using the wrong type. |
Check the route definition and retrieve the correct route type or argument key. |
| Type-safe route does not compile |
Serialization plugin, imports, or Navigation dependency is missing or incompatible. |
Check Gradle configuration and compatible library versions. |
| String route fails with special characters |
Route values were concatenated without proper encoding. |
Encode route values correctly or use type-safe routes. |
| Details screen shows no data |
The identifier does not match a record, or loading has not completed. |
Validate the ID and implement loading, success, and error states. |
| Data is stale |
A full object or old value was passed between screens. |
Pass a stable identifier and reload current data when appropriate. |
| Optional argument causes a crash |
Nullable input was treated as non-null. |
Use a nullable type and handle the missing value. |
| Back navigation loses a result |
The result was stored in the wrong back-stack entry or observed at the wrong lifecycle moment. |
Review the previous entry, lifecycle, and result-consumption logic. |
| Screen cannot access repository data |
Data loading responsibilities are not connected to the destination. |
Use a ViewModel and repository or another appropriate data layer. |
21. Best Practices
- Pass the minimum required data: Prefer stable IDs over large objects.
- Use typed routes: Prefer type-safe navigation when supported by your project setup.
- Separate navigation from presentation: Pass callbacks into reusable screens rather than coupling every screen to a global controller.
- Use ViewModels for UI state: Let a suitable state holder manage loading and data retrieval.
- Validate received values: Correct argument types do not guarantee that the requested record exists.
- Handle optional values explicitly: Provide meaningful fallbacks and error states.
- Keep route arguments small: Avoid passing large collections, images, or complex application state.
- Consider lifecycle behavior: Make sure data can be reloaded when a destination is recreated.
- Test navigation paths: Verify forward navigation, Back navigation, missing records, and invalid inputs.
- Protect private data: Navigation arguments are not a substitute for authentication or authorization.
22. Practical Exercise
Project: Create a Course Catalog application using Jetpack Compose Navigation.
Requirements
- Create a Home screen that displays a list of courses.
- Define a serializable route for the Course Details destination.
- Pass the selected course ID when the user taps a course.
- Retrieve the ID at the destination using the route object.
- Load the course details from a sample list, repository, or database.
- Show a useful message when the requested course does not exist.
- Add an optional category filter or display preference argument.
- Test navigation to multiple courses and back to the course list.
- Verify that the UI handles loading and error states appropriately.
Expected outcome
The application should navigate from the course list to the correct details screen, transfer the required arguments safely, and display the appropriate course data without passing the entire catalog through navigation.
23. Interview Questions and Answers
Q1. What is a navigation destination in Android?
Answer: A destination is a screen or navigable location defined within an application's navigation graph.
Q2. What is a navigation argument?
Answer: An argument is a value supplied to a destination so it can display or load the required information.
Q3. What is the purpose of NavController?
Answer: NavController manages navigation operations, including moving to another destination and handling the navigation back stack.
Q4. How can you pass data using type-safe Navigation Compose routes?
Answer: Define a serializable route object or data class with the required properties, pass an instance to navigate(), and retrieve it with toRoute<T>() in the destination.
Q5. Why is passing an ID often better than passing an entire object?
Answer: An ID keeps navigation arguments small and allows the destination to load the latest data from the application's data source.
Q6. How do you handle optional arguments?
Answer: Use nullable properties or meaningful default values and handle the absence of the value in the destination.
Q7. What is the role of a ViewModel?
Answer: A ViewModel manages UI-related state and can coordinate data retrieval for the destination through a repository.
Q8. Can data be returned to a previous destination?
Answer: Yes. A common approach is to place a result in the appropriate previous back-stack entry's SavedStateHandle and observe it when the previous destination becomes active.
Q9. What is the difference between string routes and type-safe routes?
Answer: String routes rely on manually constructed route patterns and argument parsing. Type-safe routes represent destinations and their arguments with Kotlin types, reducing manual string handling.
Q10. Are navigation arguments a security mechanism?
Answer: No. Arguments carry data; applications must still validate records and enforce authentication and authorization where required.
24. Quick Revision
- Destinations represent navigable screens or locations.
- Routes identify destinations and can carry arguments.
NavController performs navigation.
NavHost connects the controller to the navigation graph.
- Type-safe routes can use serializable objects and data classes.
navigate() moves to another destination.
toRoute<T>() retrieves a typed route in supported Navigation Compose versions.
- Optional values can use nullable properties or default values.
- Passing IDs is usually preferable to passing large objects.
- ViewModels and repositories are appropriate places to manage destination data loading.
- Always validate arguments and handle missing data.
25. Conclusion
Passing arguments and data between destinations is an essential skill in Android application development. Jetpack Compose Navigation makes it possible to move between screens while providing each destination with the information it needs.
By understanding routes, typed arguments, optional values, ViewModels, repositories, and back-stack results, developers can build navigation flows that are easier to maintain, safer to evolve, and more reliable when application state changes.
Continue learning: Explore the Android App Development Course | Register for a Course Demo | Visit JustAcademy