I'm theory crafting how to approach callback hell ...
# compose
p
I'm theory crafting how to approach callback hell in compose. Let's say I have code like below. How would you avoid passing callback down to the child?
Copy code
@HiltViewModel
class WorldViewModel @Inject constructor(): ViewModel() {
    fun hello() {
    }
}

@Composable
fun World(vm: WorldViewModel = hiltViewModel()) {
    Continent(onClick = vm::hello)
}

@Composable
fun Continent(onClick: () -> Unit) {
    Country(onClick)
}

@Composable
fun Country(onClick: () -> Unit) {
    State(onClick)
}

@Composable
fun State(onClick: () -> Unit) {
    Button(onClick = onClick) { }
}
๐Ÿ‘ 1
Maybe like so?
Copy code
val LocalActiveVM = compositionLocalOf<WorldViewModel> { error("facepalm") }

@Composable
fun World(vm: WorldViewModel = hiltViewModel()) {
    CompositionLocalProvider(LocalActiveVM provides vm) {
        Continent()
    }
}

@Composable
fun Continent() {
    Country()
}

@Composable
fun Country() {
    State()
}

@Composable
fun State() {
    val vm = LocalActiveVM.current
    Button(onClick = vm::hello) { }
}
c
I don't think that's a good idea, you're now passing state implicitly. Now, the user of the top-level component doesn't know what parameters they should provide.
โž• 3
a
yeah the cure is significantly worse than the disease here. (Also this isn't what the term "callback hell" means ๐Ÿ™‚ )
๐Ÿ˜… 2
K 2
โ˜๏ธ 4
a lot of advice about react's Context is relevant for compose CompositionLocals, between this and the other thread above, you might find some of the docs here helpful: https://reactjs.org/docs/context.html#before-you-use-context
๐Ÿ‘ 1
j
I'm having kind of the same issues. What about creating a ViewModel thats a singleton and grabbing the singleton on the final function that sets the state? would that update the state and change the UI way back in the parent funtion?
p
That's similar approach, that I suggested, so it's also not a good idea then
a
as a UI grows the example in the OP of this thread isn't particularly representative. When do you have a world with only a single continent with a single country on it? ๐Ÿ™‚
as this example becomes more real and
Country
is traversing a data structure of states and calling
State
for each one, it's probably going to add some additional data to the callback you would pass to
Country
, doing a bit of function currying for the
onClick
it passes down to each call to
State
and perhaps reporting back up something like an
onStateClick: (State) -> Unit
to the caller of
Country
which might in turn add some of its own data back up
each link in that chain is adding value to the abstraction above it, and being explicit with the abstraction below it keeps those lower level abstractions from knowing too much about the overall system, making them easier to isolate, modify, and test
by contrast, if you use something like a CompositionLocal to make all of your individual leaf nodes in a system aware of the ViewModel itself, then any time you make a change to the ViewModel you have a lot of client code that can potentially be affected by that change
p
would my proposition be more valid in some other specific use case, like in more private context? I completely agree, that if you have 3 public composables, that they should not know nothing about each other, for all the reasons you pointed out. What if Continent, Country and State were all private functions? Basically like using a global field in class, for contrast.
a
to make sure I understand the analogy,
Copy code
class MyClass {
  private var localData: Int = 0

  fun doSomething(input: Int) {
    localData = input
    performDoSomething()
  }

  private fun performDoSomething() {
    someDependency.doWork(localData)
  }
}
?
because I think that is a rather excellent example of why you should prefer passing parameters over creating more widely scoped state such that leaf dependencies are harder to reason about ๐Ÿ™‚
p
Basically like that yeah
j
Hm so I have an app I wrote in Swift UI and trying to create a jetpack compose version of it for android users. I have a User login backend already made and there's really no way around having to chain multiple async network callbacks. Once the user gets their token I then issue another request to get their user data. This user data in swiftUI just went into an observable object that would then populate all the views with the users account information. Do I really need to send a bajillion callbacks and state variables through all these networking functions to update the UI once I get my user data? I was looking at CompositionLocal but I can't send
@Composable
into callbacks. Maybe I can send an entire user data object + a callback through all the callbacks to update them once the user signs in? I can't fathom passing all these single states + their callbacks and update them all once my user is finally logged in.
Maybe a Map key/value pairs of all my user info would work?
a
I have to admit I'm a bit confused by the description ๐Ÿ™‚ specifically the jump from, "This user data in swiftUI just went into an observable object that would then populate all the views with the users account information" to, "Do I really need to send a bajillion callbacks and state variables through all these networking functions to update the UI once I get my user data?" - I'm not sure where the assumption of the second part comes in
the thread here was about passing state and event handling callbacks down a composition for building UI; the steps that happen after you already have any data you want to populate it with
j
Copy code
struct AccountView : View {
    @ObservedObject private var finUser = FinUser.sharedInstance
    @State var paymentView: Int? = nil
    @State var signoutView: Int? = nil
    @State var passwordResetView: Int? = nil
    @State var showDropIn = false
    @State var showingAccountSheet: Bool = false
    @State var showingPasswordSheet: Bool = false
    
    var body: some View {
        VStack {
            HStack{
                Image(systemName: "person.crop.circle.fill")
                    .padding()
                    .foregroundColor(Color.gray)
                    .font(.system(size: 60))
                Spacer()
            }
            if(!showDropIn){
                DynamicContentCell(imgName: "none", titleStr: "First Name", contentStr: $finUser.firstName)
                DynamicContentCell(imgName: "none", titleStr: "Last Name", contentStr: $finUser.lastName)
                DynamicContentCell(imgName: "greaterthan", titleStr: "Email", contentStr: $finUser.email).onTapGesture {
                        showingAccountSheet = true
                    }
                NavigationLink(destination: ResetPasswordView(), tag: 1, selection: $passwordResetView) {
                    ContentCell(imgName: "greaterthan", titleStr: "Password", contentStr: "****************").onTapGesture {
                        self.passwordResetView = 1
                    }
                }

                PaymentCell(paymentType: $finUser.paymentType, titleStr: "Payment", contentStr: $finUser.paymentIdentifier).onTapGesture {
                    if(!showDropIn){
                        showDropIn = true
                    }
                }
            }
            Divider()
            Spacer()
            if self.showDropIn {
                let token = finUser.userData["customerToken"].stringValue
                BTDropInRepresentable(authorization: token, handler: { controller, result, error in
                    if let error = error {
                        print("Error: \(error.localizedDescription)")
                    } else if let result = result, result.isCancelled {
                        handleCancelMethod()
                    } else {
                        handlePaymentMethod(result: result)
                    }
                    self.showDropIn = false
                }).edgesIgnoringSafeArea(.vertical)
            }
        }.navigationBarTitle(Text("Account"))
        .background(EmptyView().actionSheet(isPresented: $showingAccountSheet) {
            ActionSheet(title: Text("Sign Out?"), message: Text("Sign out or delete your account."), buttons: [
                .default(Text("Sign Out")) { _ = finUser.signOutUser() },
                .default(Text("Delete Account")) { finUser.removeUser() },
                .cancel()
            ])})

    }
So in my swift code I can update my FinUser singleton+observable object after signing in my user. The data from my server populates the object properties and triggers a state update to add things like
$finUser.firstName
In jetpack it seems like i need to have the
Copy code
val firstName = rememberSaveable { mutableStateOf("") }
be passed into my signin flow + the callback. This
firstName
val would also need to passed into the account view
a
I'm not a swiftui expert but I assume that something like
FinUser.sharedInstance
generally maps to something like a global or
CompositionLocal
and would work the same way here should you choose to set things up that way. If you pass a
FinUser
instance as a parameter to a
@Composable
function it'll both recompose if the instance changes at the call site, and granularly recompose if any observable mutable properties of it change
j
I think it may be better to pass maybe a Map as a mutableStateMapOf down into my requests from the top level and have that shared by the views.
So I can get my user data, send the data into the callback and update the mutableStateMapOf which could then update the views
I tried the CompositionLocal way but
@Composable
functions wouldn't work as callbacks for some reason
I get
org.jetbrains.kotlin.diagnostics.SimpleDiagnostic@5219232e (error: could not render message)
in android studio when I try to pass it a
@Composable
function
a
what code did you try?
CompositionLocals
have to be read in composition but you can read them into a local val and use that val in callbacks, e.g.
Copy code
val current = LocalFoo.current
Button(onClick = { current.doStuff() }) { ...
j
Copy code
@Composable
fun loginUser(email: String, password: String){
    var finUser = FinUser()
    if(isValidEmail(email) && password.length >= 8) {
        val hashedPassword = createHash(unhashed = password)
        finUser.loginUser(email = email, password = hashedPassword,
                          onCompletion =  {rspObj -> loginUserRsp(response = rspObj)},
                          onError = {rspObj -> loginUserError(response = rspObj) })
    } else if(!isValidEmail(email)){
        // handle email mistake
    }else if(password.length < 8){
        // handle password not long enough
    }
}
Copy code
Button(
    onClick = {loginUser(email = email.toString(),
                     password = password.toString())
              },
    modifier = modifier.then(Modifier.shadow(elevation = 5.dp))
) {
    Text("Login")
}
gives me a red line with the error
Copy code
@Composable
fun loginUserRsp(response: MutableMap<String, Any>){
    if(response.containsKey("key")){
        val finUser = LocalActiveUser.current
        finUser.setToken(token = response["key"] as String, tokenType = "Token")
        finUser.loggedIn = true
    }else{
        //TODO ADD ERROR
    }
}
a
loginUser
in that example should not be
@Composable
nor should
loginUserResp
j
so i didn't have it originally but then i get an error on
Copy code
val finUser = LocalActiveUser.current
org.jetbrains.kotlin.diagnostics.SimpleDiagnostic@4ec43859 (error: could not render message)
Copy code
fun App() {
    val finUser = FinUser()
    CompositionLocalProvider(LocalActiveUser provides finUser) { *** }
Copy code
val LocalActiveUser = compositionLocalOf<FinUser> { error("No user found!") }

class FinUser { *** }
And this is the error I get e:
/Users/jasoninbody/AndroidStudioProjects/MyApplication/app/src/main/java/com/example/myapplication/view/LoginView.kt: (124, 5): Functions which invoke @Composable functions must be marked with the @Composable annotation
a
right, CompositionLocals can't be read outside of composition. (And you'll want to do
val finUser = remember { FinUser() }
to make sure you don't create new ones when
App
recomposes)
back in a bit
j
I think I might have found the issue. There's composable methods inside my user object let me get rid of them and maybe that will fix it nope
So I think its impossible to use compositionLocalOf in a network callback because you have to invoke
@Composable
to get the composition local. I'll have to chain a map and callback through all the networking I guess
a
I'm still puzzled by the conclusion of maps and chaining. A login process needs to write something that is visible to the composition, but no part of that needs to involve networking code knowing about your UI code or maps ๐Ÿค”
j
Oh, maybe I miss understanding maps? I have a bunch of data that when returned from my server would update object properties in swift
Copy code
self.email = ""
ย ย ย ย self.firstName =ย ""
ย ย ย ย self.lastName = ""
ย ย ย ย self.accountPassword = "****************"
ย ย ย ย self.token = ""
ย ย ย ย self.tokenType = ""
ย ย ย ย self.userData = [:]
ย ย ย ย self.paymentType = ""
ย ย ย ย self.paymentIdentifier = "No Payment On File"
ย ย ย ย self.paymentToken = ""
ย ย ย ย self.activeRentals = []
ย ย ย ย self.historyRentals = []
I was thinking I could make a data class like that would have key/value pairs
"email":"<mailto:email@email.com|email@email.com>"
in kotlin
Copy code
data class UserData(
    val email: String?,
    val firstName: String?,
    val lastName: String?,
    // ect
)
and pass the that back up with a callback when my network requests are all finished
sorry if its confusing... im confused myself and trying to get a grasp on how state vars work in compose. It seems to be much more rigid than react or swiftui
a
I think we're conflating mechanics and design practices in this thread a bit ๐Ÿ™‚
mechanically speaking, compose's snapshot state lets you do this:
Copy code
var something by mutableStateOf(initial)
in classes, even at the top-level as globals if you want, and it's observable. Compose will automatically observe changes to any snapshot state container declared this way
j
Ohhhh so I can use a class as a mutable state?
a
a class can declare its properties
by mutableStateOf(initialValue)
:
Copy code
class MyClass(
  initialFoo: Foo,
  initialBar: Bar
) {
  var foo by mutableStateOf(initialFoo)

  var bar by mutableStateOf(initialBar)
    private set // this can only be changed by this class

  // ...
}
in
@Composable
functions you use
var foo by remember { mutableStateOf(initial) }
because you want the same observable holder instance to persist across recompositions - be part of that instance of the composable, not recreate every time something about it recomposes
but you could just as easily use the
MyClass
from above and write
Copy code
val myClassInstance = remember { MyClass() }
and use it, since
myClassInstance.foo
and
myClassInstance.bar
are observable
j
Ah ok! I think im getting now. I've got my user object able to change the UI states now. Even the slowest horse cross the finish line...
๐Ÿ™‚ 1
a
you might find @Zach Klippenstein (he/him) [MOD]โ€™s series https://dev.to/zachklipp/series/12895 to be helpful in addition to the official docs and codelabs about compose and state