Home > Software design >  How to update my composable upon service property update?
How to update my composable upon service property update?

Time:07-28

I'm using a service locator (as advised in https://developer.android.com/training/dependency-injection#di-alternatives, but I'll switch to proper DI later I promise) to handle auth in my app. I have an authentication service that has a user property that I set and unset using logIn and logOut methods

I'd like my ContentView to react to changes in auth.user but I can't quite figure out how. I've tried wrapping it into by remember { mutableStateOf() } but I don't see any update upon login.. any idea what I am missing?

Thanks in advance! (snippets below)


@Composable
fn ContentView() {
    val auth = ServiceLocator.auth
    var loggedInUser: User? by remember { mutableStateOf(auth.user) }  // <-- I would like my composable to react to changes to auth.user

    if (loggedInUser) {
        ViewA()
    } else {
        ViewB()
    }
}

object ServiceLocator {
    val auth = AuthenticationService()
}

class AuthenticationService {

    var user: User? = null

    fun logIn() { 
        // sets user...
    }

    fun logOut() {
        // undefs user...
    }

CodePudding user response:

In your code snippet on this line

var loggedInUser: User? by remember { mutableStateOf(auth.user) }

you are creating an instance of MutableState<User?> with an initial value of the value that is at that time referenced by auth.user. Due to remember { } this initialization happens only when the composable ContentView enters composition and then the MutableState instance is remembered across recompositions and reused.

If you later change the variable auth.user no recomposition will happen, because the value stored in loggedInUser (in the mutable state) has not changed.

The documentation for mutableStateOf explains what this call actually does behind the scenes

Return a new MutableState initialized with the passed in value.

The MutableState class is a single value holder whose reads and writes are observed by Compose. Additionally, writes to it are transacted as part of the Snapshot system.

Let's dissect this piece of information.

Return a new MutableState initialized with the passed in value.

Calling mutableStateOf returns a MutableState instance that is initialized with the value that was passed as the parameter.

The MutableState class is a single value holder

Every instance of this class stores a single value of state. It might store other values for the implementation purposes, but it exposes only a single value of state.

whose reads and writes are observed by Compose

Compose observes reads and writes that happen to instances of MutableState

This is the piece of information that you have missed. The writes need to happen to the instance of the MutableState (loggedInUser in your case), not to the variable that has been passed in as the initial value (auth.user in your case).

If you really think about it, there is no built-in mechanism in Kotlin to observe changes to a variable, so it is understandable that there has to be a wrapper for Compose to be able to observe the changes. And that we have to change the state through the wrapper instead of changing the variable directly.


Knowing all that you could just move the mutable state into AuthenticationService and things would work

import androidx.compose.runtime.mutableStateOf

class AuthenticationService {
    var user: User? by mutableStateOf(null)
        private set
    
    // ... rest of the service
}

@Composable
fun ContentView() {
    val auth = ServiceLocator.auth
    // no remember { } block this time because now the MutableState reference is being kept by
    // the AuthenticationService so it won't reset on recomposition
    val loggedInUser = auth.user

    if (loggedInUser != null) {
        ViewA()
    } else {
        ViewB()
    }
}

However now your AuthenticationService depends on mutableStateOf and thus on the Composable runtime which you might want to avoid. A "Service" (or Repository) should not need to know details about the UI implementation.

There are other options to track state changes and not depend on Compose runtime. From the documentation section Compose and other libraries

Compose comes with extensions for Android's most popular stream-based solutions. Each of these extensions is provided by a different artifact:

  • Flow.collectAsState() doesn't require extra dependencies. (because it is part of kotlinx-coroutines-core)

  • LiveData.observeAsState() included in the androidx.compose.runtime:runtime-livedata:$composeVersion artifact.

  • Observable.subscribeAsState() included in the androidx.compose.runtime:runtime-rxjava2:$composeVersion or androidx.compose.runtime:runtime-rxjava3:$composeVersion artifact.

These artifacts register as a listener and represent the values as a State. Whenever a new value is emitted, Compose recomposes those parts of the UI where that state.value is used.

Example using a Kotlin MutableStateFlow

// No androidx.compose.* dependencies anymore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow

class AuthenticationService {
    private val user = MutableStateFlow<User?>(null)
    val userFlow = user.asStateFlow()

    fun logIn() {
        user.value = User(/* potential parameters */)
    }

    fun logOut() {
        user.value = null
    }
}

And then in the composable we collect the flow as state.

import androidx.compose.runtime.collectAsState

@Composable
fun ContentView() {
    val auth = ServiceLocator.auth
    val loggedInUser = auth.userFlow.collectAsState().value

    if (loggedInUser != null) {
        ViewA()
    } else {
        ViewB()
    }
}

To learn more about working with state in Compose see the documentation section on Managing State. This is fundamental information to be able to work with state in Compose and trigger recompositions efficiently. It also covers the fundamentals of state hoisting. If you prefer a coding tutorial here is the code lab for State in Jetpack Compose.

Eventually (when you app grows in complexity) you might want to put another layer between your Service/Repository layer and your UI layer (the composables). A layer that will hold and manage the UI state so you will be able to cover both positive outcomes (a successful login) and negative outcomes (a failed login).

If you are going the MVVM (Model-View-ViewModel) way or the MVI (Model-View-Intent) way, that layer would be covered by ViewModels. In that case the composables manage only some transient UI state themselves, while they get (or observe) the rest of the UI state from the VMs and call the VMs to perform actions. The VMs then interact with the Service/Repository layer and update the UI state accordingly. An introduction to handling the state as the complexity increases is in the video from Google about Using Jetpack Compose's automatic state observation.

  • Related