0

I'm working on a Jetpack Compose Android app that displays a list of trains. I have Screen A that shows a list of trains and Screen B that displays the details of a selected train. Each screen has its own ViewModel (using hiltViewModel()).

Here’s the flow:

  1. Screen A loads and fetches a list of trains via an API.
  2. User taps on a train.
  3. App navigates to Screen B (TrainDetailsScreen) to show the train's full info.

Question:

Where is the best place to trigger the API call to fetch full train details?

  • In Screen A, before navigation and pass the data to B?
  • Or let Screen B make its own API call using the train number/id?

What is the recommended pattern in MVVM + Jetpack Compose (each screen having its own ViewModel)?

Screen A (Train List)

    @Composable
fun TrainListScreen(navController: NavController, viewModel: TrainListViewModel = hiltViewModel()) {
    val state by viewModel.trainList.collectAsState()

    LazyColumn {
        items(state.trains) { train ->
            Text(
                text = train.name,
                modifier = Modifier.clickable {
                    navController.navigate("trainDetail/${train.number}")
                }
            )
        }
    }
}

Navigation Setup

NavHost(navController, startDestination = "trainList") {
    composable("trainList") { TrainListScreen(navController) }
    composable(
        "trainDetail/{trainNumber}",
        arguments = listOf(navArgument("trainNumber") { type = NavType.StringType })
    ) { backStackEntry ->
        val trainNumber = backStackEntry.arguments?.getString("trainNumber") ?: ""
        TrainDetailScreen(trainNumber)
    }
}

Screen B (Train Detail)

@Composable
fun TrainDetailScreen(
    trainNumber: String,
    viewModel: TrainDetailViewModel = hiltViewModel()
) {
    val state by viewModel.trainDetail.collectAsState()

    LaunchedEffect(trainNumber) {
        viewModel.loadTrainDetail(trainNumber)
    }

    if (state.isLoading) {
        CircularProgressIndicator()
    } else {
        Text("Train Name: ${state.train?.name}")
        // show more details
    }
}

Which screen should be responsible for the API call? Is LaunchedEffect the correct way to make api?

3 Answers 3

1

The best place to trigger the API call to fetch full train details would be Screen B's ViewModel. You can have a common repository class for defining the calls and all, then you can inject the same into both the ViewModels (of Screen A and Screen B). Thereafter, just let the ViewModel change its own state (by relying on the concerned functions from the repository) and you will be good to go. The navigation flow will surely not be the right place to do the API call because that way you are violating the Single Responsibility Principle.

Sign up to request clarification or add additional context in comments.

2 Comments

Using LaunchedEffect is the correct way to get data(making api call) in B?
I would rather say it is one of the ways that work. You may also take a look at the SavedStateHandle approach, which is supposed to be better in terms of persistence over configuration changes and process death. Also, you may analyze the "recommended" patterns here: github.com/android/nowinandroid
0

The second screen should be the one responsible for loading the data as it's the one showing the data.

Using LaunchedEffect(trainNumber in your composable to load data isn't all wrong, but it's generally not recommended for a few reasons related to architecture and lifecycle.

the LaunchedEffect block may get called more than once upon key change or when the composable is recomposed unexpectedly.

This also makes the testing harder since it can't be tested in isolation anymore.
Businesss logic is being mixed with UI layer.

Better approach would be to let the viewModel handle everything related to data.

@HiltViewModel
class TrainDetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val repository: TrainRepository,
    /* Your injected repositories */
): ViewModel() {
    private val trainNumber = savedStateHandle.getStateFlow("trainNumber", null) // Or your preferred initial value

    val trainDetails: StateFlow<TrainDetailsState> = trainNumber
        .filterNotNull()
        .flatMapLatest { number ->
            flow {
                emit(TrainDetailsState.Loading)
                emit(loadTrainData(number))
            }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrainDetailsState.Loading)

    private suspend fun loadTrainData(trainNumber: String): TrainDetailsState {
        return try {
            val details = repository.getTrainDetails(trainNumber)
            TrainDetailsState.Success(details)
        } catch (e: Exception) {
            TrainDetailsState.Error(e.localizedMessage ?: "Unknown error")
        }
    }

}

sealed interface TrainDetailsState {
    data object Loading: TrainDetailsState
    data class Success(val details: TrainDetails): TrainDetailsState
    data class Error(val message: String): TrainDetailsState
}
  • Screen A will remain the same.

  • Navigation Setup

NavHost(navController, startDestination = "trainList") {
    composable("trainList") { TrainListScreen(navController) }
    composable(
        "trainDetail/{trainNumber}",
        arguments = listOf(navArgument("trainNumber") { type = NavType.StringType })
    ) { backStackEntry ->
        // Removed passing trainNumber into the composable
        TrainDetailScreen()
    }
}
  • Screen B (Train Detail)
@Composable
fun TrainDetailScreen(
    viewModel: TrainDetailViewModel = hiltViewModel()
) {
    // Recommended to use collectAsStateWithLifecycle() over collectAsState()
    val state by viewModel.trainDetails.collectAsStateWithLifecycle()
    
    // may require explicit type casting
    when(state) {
        TrainDetailsState.Loading -> CircularProgressIndicator()
        is TrainDetailsState.Success -> { Text("Train name: ${state.train.name}") }
        is TrainDetailsState.Error -> { Text("Error occurred: ${state.message}") }
    }
}

1 Comment

Interesting, but could you explain How do you reload or refresh the details with this approach.
0

If you do it in B's ViewModel, you can also add loading + retry if failed views to B.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.