Kwirke
Posted on June 18, 2021
ApolloJS is a GraphQL framework that lifts a lot of heavy work in both client and server. It also aims to provide a good solution for local state management in client, but one can quickly see it is still very young in this business: The docs give 3 different solutions for state management, but none of them is extensibly documented. Also, none of them allow for event dispatching nor state machines.
In the official ApolloJS docs, there is half an example of a shopping cart. As the lack of explanation left me puzzled, I tried several things, and I am going to explain here a solution that is both simple and idiomatic to Apollo.
Ingredients
In this example we are assuming we have:
- A datasource with methods
getItem(itemId)
andgetAllItems()
- A GraphQL proxy, implemented with apollo-server
- Ability to edit this proxy
- The next schema:
type Item {
id: String
name: String
price: Int
}
type Query {
allItems: [Item!]!
item(id: String!): Item
}
The Cart in the Client
In order to implement the cart, we want to store in the client's state the minimum amount of data we can.
A possible implementation would be to have a fully-fledged store and replicate there the data of all the selected items in the cart, but we already have this data in the Apollo cache, and we want to take advantage of that.
The minimum data we need is the list of selected IDs, so that's what we will be storing.
But what happens if we haven't fetched the selected items yet? We will need a way to fetch their data, but we only have a way to get one or all the items. Even worse: In a real case, the allItems
query will be paginated and we won't have any guarantee we have fetched the selected items.
The Server
In order to fetch the missing data, we will need a query that fetches only the selected items.
Let's add the new query to the schema:
type Query {
allItems: [Item!]!
item(id: String!): Item
items(ids: [String!]!): [Item!]!
}
We also need to add the appropriate resolver:
const resolvers = {
items: (_, {ids}, {dataSources}) => (
Promise.all(ids.map(
id => dataSources.itemsAPI.getItem(id)
))
),
...
}
The Client
In order to have local state in Apollo, we extend the schema with local fields as follows:
const typeDefs = gql`
extend type Query {
cartItemsIds: [Int!]!
}
`
Apollo gives us three ways of handling this local state, each one worse than the rest:
Rolling our own solution
This means having our own local dataSource (localStorage, Redux store, etc).
In order to read the data, we can write a read resolver for our client queries that resolve against this local dataSource.
In order to modify the data, the documentation doesn't say anywhere that we can write resolvers for mutations, and tells us to directly call the dataSource, coupling it everywhere, and afterwards calling manually cache.evict({id, fieldName})
in order to force the refresh of all the dependents of the modified entity.
Using the cache
Just as in the previous, we write a read resolver, but we will use Apollo's cache itself as a dataSource, thus avoiding the call to cache.evict
.
This means we will have to call readQuery
with a GraphQL query in order to resolve a GraphQL query. It also means we will need to add types to the extended schema, and that we won't be able to store anything that is not a cacheable entity (has an ID) or is not directly related to one.
We want to store an array of IDs, which shouldn't need to have an ID on its own because it is not an instance of anything.
This solution would force us to implement this as a boolean isInCart
client field in the Item
itself, querying the cache and filtering all items that have isInCart === true
. It is fine for the cart case, but not extensible to things that are not related to entities in the cache. We don't want to be forced to use different methods for different local data.
It will also force us to call directly writeQuery
in order to modify the data. All in all, suboptimal at best.
Reactive Variables
The chosen solution.
We create a global (ehem) reactive variable, then write a resolver that will retrieve its value, and we can also check and modify the variable in any component using the useReactiveVar
hook.
This solution still forces us to read data using a different paradigm that the way we write it. However, we won't have to use cache.evict
nor the suicide-inducer cache API.
Client Implementation
We create the reactive variable and check it in the resolver for our local cartItemsIds
query:
const itemsInCart = makeVar([]) // We start with no item selected
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: { // boilerplate
cartItemIds: {
read() {
return itemsInCart()
}
}
}
}
}
})
const client = new ApolloClient({
uri: 'https://...',
typeDefs,
cache,
})
Now we can make the following client query from any component:
query ItemIdsInCart {
cartItemsIds @client
}
And we can combine this query with the new server query in order to get all the data for each item:
const GET_CART = gql`
query GetCart($itemIds: [String!]!) {
cartItemIds @client @export(as: "itemIds")
items(ids: $itemIds) {
id
name
price
}
}
`
const Cart = () => {
const {loading, error, data} = useQuery(GET_CART)
if (loading || error) return null
return (
<ul>
{data.items.map(item => (
<li key={item.id}>
{`${item.name}...${item.price}$`
</li>
))}
</ul>
)
}
Even a better solution
If we look closely, we will see we could fetch the reactive variable from the component, and thus avoid the local query altogether. Let's see how:
First, we ignore Apollo docs and remove the pyramid of doom from the InMemoryCache:
const itemsInCart = makeVar([])
const client = new ApolloClient({
uri: 'https://...',
cache: new InMemoryCache(),
// no typeDefs either
})
Now, we can use the reactive variable directly in the component without any sense of guilt:
const GET_CART = gql`
query GetCart($itemIds: [String!]!) {
items(ids: $itemIds) {
id
name
price
}
}
`
const Cart = () => {
const cartItemIds = useReactiveVar(itemsInCart)
const {loading, error, data} = useQuery(GET_CART, {
variables: {itemIds: cartItemIds},
})
if (loading || error) return null
return (
<ul>
{data.items.map(item => (
<li key={item.id}>
{`${item.name}...${item.price}$`}
</li>
))}
</ul>
)
}
Modifying the Cart
So how do we modify the variable? We call it with the new value, and all dependents will magically update and all queries will be refetched.
We will add a removeFromCart function to the component to see how this works:
const Cart = () => {
const cartItemIds = useReactiveVar(itemsInCart)
// + vvv
const removeFromCart = useCallback(id => {
const remainingItems = cartItemIds.filter(item => item !== id)
// This will trigger the re-render due to useReactiveVar
itemsInCart(remainingItems)
}, [cartItemIds])
// + ^^^
const {loading, error, data} = useQuery(GET_CART, {
variables: {itemIds: cartItemIds},
})
if (loading || error) return null
return (
<ul>
{// Call removeFromCart on click
data.items.map(item => (
<li key={item.id} onClick={() => removeFromCart(item.id)}>
{`${item.name}...${item.price}$`
</li>
))}
</ul>
)
}
Conclusions
You can find the full code here:
Server: codesandbox.io/s/awesome-northcutt-iwgxh
Client: codesandbox.io/s/elegant-mclean-ekekk
A special thanks to this article by Johnny Magrippis for the environment set-up:
https://medium.com/javascript-in-plain-english/fullstack-javascript-graphql-in-5-with-code-sandbox-374cfec2dd0e
What is the utility of custom local-only fields, then?
As far as I have seen, none. I haven't found any way to make local queries derive the output from several remote queries. As these dependencies are meant to be solved in the component, we may as well connect it to Redux for all the local state and make all the queries based on the values in the state. We will have full reactivity as well, and a coherent way to get and set all local state.
I don't have a lot of experience with Apollo and this conclusion should be taken cautiously. This article is only meant as a tutorial as well as a critique to Apollo's incomplete docs.
If this helped you in any way or you know more than I do, please let me know.
Posted on June 18, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.