apollographql / apollo-kotlin

:rocket:  A strongly-typed, caching GraphQL client for the JVM, Android, and Kotlin multiplatform.
https://www.apollographql.com/docs/kotlin
MIT License
3.76k stars 653 forks source link

Provide better Jetpack Compose support #3497

Open martinbonnin opened 3 years ago

martinbonnin commented 3 years ago

Considerations

sonatard commented 1 year ago

With Jetpack compose getting more and more traction, should we move our tutorial to Jetpack Compose?

It sounds great.

Which existing users can we speak with who already are using Jetpack Compose with our Kotlin client, what has their experience been so far?

We created a declarative interface like apollo-web. because apollo-kotlin doesn't have it. It would be hard for all developers to do that. And not yet able to handle all the options of the Apollo web. I would be very happy if you could officially support a declarative interface like apollo web in apollo kotlin. And it would have a great impact on the spread of Jetpack Compose and GraphQL on Android.

context(ApolloClient)
@Composable
fun UserProfile() {
    val (data, loading, error) = apollo.query(q = UserProfileQuery())
    when {
        loading -> Spinner()
        error != null -> ErrorDialog(error = error)
        else -> Text(text = data.viewer.name)
    }
}
function UserProfile() {
  const { data, loading, error } = useQuery(UserProfileDocument);
  if (loading) {
    return <Spinner />;
  }
  if (error) {
    return <ErrorDialog error={error}/>;
  }
  return <div>data.viewer.name</div>;
};

If this can be achieved, Web and Android development will feel very close.

BoD commented 1 year ago

Thanks @sonatard for the feedback! This looks interesting! The exact syntax for the Kotlin code would have to be a bit different, right? (I'm confused by the when there). Unless I'm missing something!

(The "Compose-ification" of the tutorial is in progress).

sonatard commented 1 year ago

@BoD

The exact syntax for the Kotlin code would have to be a bit different, right? (I'm confused by the when there). Unless I'm missing something!

What seems to be the difference when ? I didn't build it, so I could be wrong.

(The "Compose-ification" of the tutorial is https://github.com/apollographql/apollo-kotlin-tutorial/issues/38).

👍

BoD commented 1 year ago

I think a possible syntax for what you suggest could resemble something like:

val result by apollo.query(q = UserProfileQuery())
when (result) {
    Loading -> Spinner()
    is Error -> ErrorDialog(error = result.error)
    is Data -> Text(text = result.data.viewer.name)
}
sonatard commented 1 year ago

I used this when expiression!

https://kotlinlang.org/docs/control-flow.html#when-expression

スクリーンショット 2023-03-09 0 58 42

But Kotlin would be better represented by an algebraic data type with a sealed interface as you wrote 👍

sonatard commented 1 year ago

Hi. We are looking forward to the development of the Compose Extension. Let me introduce an option that we value when writing Jetpack Compose.

It is the skip option for useQuery. By using this, you can suppress the execution of the query without using Effect. https://www.apollographql.com/docs/react/api/react/hooks/#params

2023-03-17 14 17 49

In React, it is mentioned that using Effect recklessly can cause problems.

https://react.dev/learn/you-might-not-need-an-effect

We like that using the skip option allows us to write more declarative code.

// Effect version
val (fetch, data, loading, error) = apollo.lazyQuery<XXXQuery>()
LaunchedEffect(key1 = value) {
        if (value != null) {
            fetch(value)
        }
}

In our wrapper, using the skip option enables the following refactoring:

// skip version
val (data, loading, error) = apollo.query(skip = value == null, q = XXXQuery)
martinbonnin commented 1 year ago

@sonatard if the query is not executed, there still need to be an initial state right? Is it going to be loading? Something else?

sonatard commented 1 year ago

Our wrapper is if skip is true, data=null loading=false error=null. I think apollo-web behaves in the same way. But I haven't confirmed it.

martinbonnin commented 1 year ago

data=null loading=false error=null

Gotcha 👍 That's an interesting one as we're usually trying to model either data != null or error != null. Will need to let that sink in a bit but thanks for the feature request 👍

sonatard commented 1 year ago

@BoD @martinbonnin Thank you for releasing the Jetpack Compose support!

For mutations, we have prepared a wrapper like the following. It is an interface aligned with Apollo Web's mutations. https://www.apollographql.com/docs/react/data/mutations/

Is there any plan to support mutations? or should we implement this individually?

context(ApolloClient)
@Composable
fun FavoriteButton() {
    val (favoriteProductMutation, data, loading, error) = apollo.mutation<FavoriteProductMutation.Data>()
    when {
        error != null -> {
            ErrorDialog(error = error)
        }
        loading -> {
            Spinner()
        }
        else -> {
            Button(onClick = {
                favoriteProductMutation()
            }) {
                Text(text = "fav")
            }
        }
    }
}
BoD commented 1 year ago

Hi! This is interesting, thanks for sharing.

At the moment, the toState() that was introduced in compose-support can be used for mutations. It returns a nullable ApolloResponse which is initially null (loading), and has .exception and .errors for the error cases, as well as .data for the success case. So in essence it is similar to what you describe (minus the destructuring syntax).

The interesting difference is the returned lambda that executes the mutation. Not 100% sure about this one, the pattern is a bit unfamiliar to me (wondering if maybe it's a common JS pattern). In toState() the call executes the operation immediately. But in your example this looks useful indeed.

That's good food for thoughts!

In the meantime, if you are starting to use the compose-support, feedback is very welcome!

sonatard commented 1 year ago

Thank you for your reply!

Not 100% sure about this one, the pattern is a bit unfamiliar to me (wondering if maybe it's a common JS pattern).

Yes, this is a very common pattern for declarative UI(React etc) in Javascript.

Nowadays, many libraries are built with this interface, such as Apollo, Relay, urql, React Query, SWR, and others.

That's good food for thoughts! In the meantime, if you are starting to use the compose-support, feedback is very welcome!

We have already implemented a mutation wrapper internally. We plan to refactor it by compose-support. Please let me consult with we again after the refactoring.

sonatard commented 1 year ago

Hi! I prepared a Query function for Jetpack Compose that supports skip and lazyQuery. Support for pagination is currently under consideration. We are further customizing this, so we don't necessarily want these to be merged into Apollo Kotlin, but we would be happy if it could be of any reference. Also, please let me know if you have any advice on how to do better.

sealed class QueryResult<out D> {
    data class Init(val exec: () -> Unit) : QueryResult<Nothing>()
    data object Loading : QueryResult<Nothing>()
    data class Success<D>(val data: D, val fetch: () -> Unit) : QueryResult<D>()
    data class Error(val error: ApolloException, val fetch: () -> Unit) : QueryResult<Nothing>()
}

@Composable
fun <D : Query.Data> ApolloClient.query(
    q: Query<D>,
    watch: Boolean = true,
    skip: Boolean = false,
    option: ApolloCall<D>.() -> Unit = {},
): QueryResult<D> {
    var executed by remember(skip) { mutableStateOf(!skip) }
    if (!executed) {
        return QueryResult.Init(exec = { executed = true })
    }

    var refetching by remember { mutableStateOf(false) }
    val keys = arrayOf(q.hashCode(), option, refetching)
    val apolloResponseFlow = remember(keys = keys) {
        when (watch) {
            true -> {
                query(q)
                    .apply(option)
                    .apply { if (refetching) fetchPolicy(FetchPolicy.NetworkOnly) }
                    .watch()
                    .onEach { refetching = false }
            }

            false -> {
                query(q)
                    .apply(option)
                    .apply { if (refetching) fetchPolicy(FetchPolicy.NetworkOnly) }
                    .toFlow()
                    .onEach { refetching = false }
            }
        }
    }

    val apolloResponse by apolloResponseFlow.collectAsState(initial = null)
    return apolloResponse?.let { response ->
        val loading = response.data == null && response.exception is CacheMissException
        val error = if (response.exception is CacheMissException) null else response.exception
        val data = response.data
        return when {
            loading || refetching -> QueryResult.Loading
            error != null -> QueryResult.Error(error = error, fetch = { refetching = true })
            data != null -> QueryResult.Success(data = data, fetch = { refetching = true })
            else -> QueryResult.Loading
        }
    } ?: QueryResult.Loading
}

Usage

Query with watch

@Composable
fun Page() {
    val query = apollo.query(q = PageQuery())
    when (query) {
        is QueryResult.Init -> Unit
        is QueryResult.Loading -> Spinner()
        is QueryResult.Error -> ErrorDialog(e = query.error)
        is QueryResult.Success -> {
            Text(text = query.data.user.name)
        }
    }
}

Query without watch

@Composable
fun Page() {
    val query = apollo.query(q = PageQuery(), watch = false)
    when (query) {
        is QueryResult.Init -> Unit
        is QueryResult.Loading -> Spinner()
        is QueryResult.Error -> ErrorDialog(e = query.error)
        is QueryResult.Success -> {
            Text(text = query.data.user.name)
        }
    }
}

Lazy Query

@Composable
fun Page() {
    val query = apollo.query(q = PageQuery(), skip = true)
    when (query) {
        is QueryResult.Init -> Button(onClick = { query.exec() }) {
            Text(text = "Lazy Query")
        }

        is QueryResult.Loading -> Spinner()
        is QueryResult.Error -> ErrorDialog(e = query.error)
        is QueryResult.Success -> {
            Text(text = query.data.user.name)
        }
    }
}

Skip Query

@Composable
fun Page(skip: Boolean) {
    val query = apollo.query(q = PageQuery(), skip = skip)
    when (query) {
        is QueryResult.Init -> Unit
        is QueryResult.Loading -> Spinner()
        is QueryResult.Error -> ErrorDialog(e = query.error)
        is QueryResult.Success -> {
            Text(text = query.data.user.name)
        }
    }
}

Refetch Query

@Composable
fun Page() {
    val query = apollo.query(q = PageQuery())
    when (query) {
        is QueryResult.Init -> Unit
        is QueryResult.Loading -> Spinner()
        is QueryResult.Error -> ErrorDialog(e = query.error)
        is QueryResult.Success -> {
            Column {
                Button(onClick = { query.fetch() }) {
                    Text(text = "Refetch")
                }
                Text(text = query.data.user.name)
            }
        }
    }
}
BoD commented 1 year ago

Thanks for sharing @sonatard , this is certainly interesting! It looks good to me. Similarly to what you shared previously I’m still intrigued about the Init case and the ability to pass skip = true as to me this can be handled at the call site instead. But maybe that simplifies the syntax.

One small detail: why not name exec as fetch? That way it’s the same for all cases.

sonatard commented 1 year ago

One small detail: why not name exec as fetch? That way it’s the same for all cases.

I have changed the name because it does not work correctly if fetch is executed before skip becomes false (executed state is true).

exec -> fetch fetch -> refetch might also be good.

Similarly to what you shared previously I’m still intrigued about the Init case and the ability to pass skip = true as to me this can be handled at the call site instead. But maybe that simplifies the syntax.

I fixed without skip. But I didn't test. I prefer having the skip feature in the query because it creates less noise when writing standard code.

Lazy Query

@Composable
fun Page() {
    var skip by remember { mutableStateOf(true) }
    val query = if (!skip) apollo.query(q = PageQuery()) else QueryResult.Init(exec = { skip = false })
    when (query) {
        is QueryResult.Init -> Button(onClick = { skip = false }) {
            Text(text = "Lazy Query")
        }

        is QueryResult.Loading -> Spinner()
        is QueryResult.Error -> ErrorDialog(e = query.error)
        is QueryResult.Success -> {
            Text(text = query.data.user.name)
        }
    }
}

Skip Query

@Composable
fun Page(skip: Boolean) {
    val query = if (!skip) apollo.query(q = PageQuery()) else QueryResult.Init(exec = { skip = false })
    when (query) {
        is QueryResult.Init -> Unit
        is QueryResult.Loading -> Spinner()
        is QueryResult.Error -> ErrorDialog(e = query.error)
        is QueryResult.Success -> {
            Text(text = query.data.user.name)
        }
    }
}