Berkay Kireçci
Berkay Kireçci

Reputation: 755

My view recompose itself muitple times after changing state

i am working on compose project. I have simple login page. After i click login button, loginState is set in viewmodel. The problem is when i set loginState after service call, my composable recomposed itself multiple times. Thus, navcontroller navigates multiple times. I don't understand the issue. Thanks for helping.

My composable :

@Composable
fun LoginScreen(
    navController: NavController,
    viewModel: LoginViewModel = hiltViewModel()
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.SpaceEvenly
    ) {
        val email by viewModel.email
        val password by viewModel.password
        val enabled by viewModel.enabled

        if (viewModel.loginState.value) {
            navController.navigate(Screen.HomeScreen.route) {
              popUpTo(Screen.LoginScreen.route) {
                 inclusive = true
              }
            }
        }

        LoginHeader()
        LoginForm(
            email = email,
            password = password,
            onEmailChange = { viewModel.onEmailChange(it) },
            onPasswordChange = { viewModel.onPasswordChange(it) }
        )
        LoginFooter(
            enabled,
            onLoginClick = {
                viewModel.login()
            },
            onRegisterClick = {
                navController.navigate(Screen.RegisterScreen.route)
            }
        )
    }

ViewModel Class:

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val loginRepository: LoginRepository,
) : BaseViewModel() {

    val email = mutableStateOf(EMPTY)
    val password = mutableStateOf(EMPTY)
    val enabled = mutableStateOf(false)
    val loginState = mutableStateOf(false)

    fun onEmailChange(email: String) {
        this.email.value = email
        checkIfInputsValid()
    }

    fun onPasswordChange(password: String) {
        this.password.value = password
        checkIfInputsValid()
    }

    private fun checkIfInputsValid() {
        enabled.value =
            Validator.isEmailValid(email.value) && Validator.isPasswordValid(password.value)
    }

    fun login() = viewModelScope.launch {
        val response = loginRepository.login(LoginRequest(email.value, password.value))
        loginRepository.saveSession(response)
        loginState.value = response.success ?: false
    }
}

Upvotes: 4

Views: 1915

Answers (1)

Phil Dukhov
Phil Dukhov

Reputation: 88307

You should not cause side effects or change the state directly from the composable builder, because this will be performed on each recomposition.

Instead you can use side effects. In your case, LaunchedEffect can be used.

if (viewModel.loginState.value) {
    LaunchedEffect(Unit) {
        navController.navigate(Screen.HomeScreen.route) {
            popUpTo(Screen.LoginScreen.route) {
                inclusive = true
            }
        }
    }
}

But I think that much better solution is not to listen for change of loginState, but to make login a suspend function, wait it to finish and then perform navigation. You can get a coroutine scope which will be bind to your composable with rememberCoroutineScope. It can look like this:

suspend fun login() : Boolean {
    val response = loginRepository.login(LoginRequest(email.value, password.value))
    loginRepository.saveSession(response)
    return response.success ?: false
}

Also check out Google engineer thoughts about why you shouldn't pass NavController as a parameter in this answer (As per the Testing guide for Navigation Compose ...)


So your view after updates will look like:

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = hiltViewModel(),
    onLoggedIn: () -> Unit,
    onRegister: () -> Unit,
) {
    // ...
    val scope = rememberCoroutineScope()
    LoginFooter(
        enabled,
        onLoginClick = {
            scope.launch {
                if (viewModel.login()) {
                    onLoggedIn()
                }
            }
        },
        onRegisterClick = onRegister
    )
    // ...
}

And your navigation route:

composable(route = "login") {
    LoginScreen(
        onLoggedIn = {
            navController.navigate(Screen.HomeScreen.route) {
                popUpTo(Screen.LoginScreen.route) {
                    inclusive = true
                }
            }
        },
        onRegister = {
            navController.navigate(Screen.RegisterScreen.route)
        }
    )
}

Upvotes: 6

Related Questions