DEV Community

Kwirke
Kwirke

Posted on • Edited on

Implementing a Shopping Cart with ApolloJS 3

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) and getAllItems()
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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!]!
}
Enter fullscreen mode Exit fullscreen mode

We also need to add the appropriate resolver:

const resolvers = {
  items: (_, {ids}, {dataSources}) => (
    Promise.all(ids.map(
      id => dataSources.itemsAPI.getItem(id)
    ))
  ),
  ...
}
Enter fullscreen mode Exit fullscreen mode

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!]!
  }
`
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

Now we can make the following client query from any component:

query ItemIdsInCart {
  cartItemsIds @client
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)