Fine-Tuning Apollo Client Caching for Your Data Graph

Ben Newman

Apollo Client Architect

GraphQL Summit

30 October 2019

How to find me:

{ github,
  twitter,
  instagram,
  facebook,
}.com/benjamn 

What is the job of a GraphQL client?

We call ourselves the data Graph company rather than the GraphQL company for a reason

Your organization should have a single canonical data graph, sprawling and possibly federated, but logically unified by an evolving schema

The job of a client application is to faithfully replicate a subset of this data graph, with all the same relationships and constraints that matter to you

Everything we’re doing here would still make sense if the community fell out of love with the GraphQL query language

To be clear, I do not think this is a very likely scenario

More likely: a healthy combination of cooperating query languages that mutually benefit from the same client-side data graph

Apollo Client 3.0 is all about giving you the tools to maintain a coherent, efficiently reactive client-side data graph

Borrowing concepts already introduced by Apollo Federation, such as composite key entity identity

Powerful new tools like garbage collection and a declarative API for managing cached field values

Talk itinerary

  • The job of a GraphQL client

  • What's new in Apollo Client 3.0

    • Single @apollo/client package
    • Cache eviction and garbage collection
    • Unified declarative configuration API
    • In-depth pagination example
    • Underlying technologies (time permitting)
  • Getting started

    • How to try the beta
    • Where to find the documentation

What’s new in

Apollo Client 3.0

One @apollo/client package for everything

The following packages have been consolidated into one:

  • apollo-client
  • apollo-utilities
  • apollo-cache
  • apollo-cache-inmemory
  • apollo-link
  • apollo-link-http
  • @apollo/react-hooks
  • graphql-tag

One @apollo/client package for everything

import React from "react";
import { render } from "react-dom";

import {
  ApolloClient,
  InMemoryCache,
  HttpLink,
  gql,
  useQuery,
  ApolloProvider,
} from "@apollo/client";

const client = new ApolloClient({
  link: new HttpLink(...),
  cache: new InMemoryCache(...),
});
const QUERY = gql`query { ... }`;

function App() {
  const { loading, data } = useQuery(QUERY);
  return ...;
}

render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById("root"),
);

That's it!

One @apollo/client package for everything




import {
  ApolloClient,
  InMemoryCache,
  HttpLink,
  gql,
  useQuery,
  ApolloProvider,
} from "@apollo/client";
  • Greatly simplifies package installation and versioning

  • No guesswork about which packages export what

  • Enables better dead code elimination and tree shaking

  • Allows coordination between gql (parsing) and HttpLink (printing)

  • Bundling examples

Cache eviction & garbage collection

  • Easily the most important missing features in Apollo Client

  • Tracing garbage collection, as opposed to reference counting

  • Important IDs can be explicitly retained to protect against collection

  • Inspiration taken from Hermes

Garbage collection

const query = gql`
  query {
    favoriteBook {
      title
      author {
        name
      }
    }
  }
`;
cache.writeQuery({
  query,
  data: {
    favoriteBook: {
      __typename: 'Book',
      isbn: '9781451673319',
      title: 'Fahrenheit 451',
      author: {
        __typename: 'Author',
        name: 'Ray Bradbury',
      }
    },
  },
});
cache.writeQuery({
  query,
  data: {
    favoriteBook: {
      __typename: 'Book',
      isbn: '0312429215',
      title: '2666',
      author: {
        __typename: 'Author',
        name: 'Roberto Bolaño',
      },
    },
  },
});

What does the cache look like now?

Garbage collection

expect(cache.extract()).toEqual({
  ROOT_QUERY: {
    __typename: "Query",
    book: { __ref: "Book:0312429215" },
  },

  "Book:9781451673319": {
    __typename: "Book",
    title: "Fahrenheit 451",
    author: {
      __ref: 'Author:Ray Bradbury',
    },
  },

  "Author:Ray Bradbury": {
    __typename: "Author",
    name: "Ray Bradbury",
  },

What does the cache look like now?




  "Book:0312429215": {
    __typename: "Book",
    title: "2666",
    author: {
      __ref: "Author:Roberto Bolaño"
    },
  },

  "Author:Roberto Bolaño": {
    __typename: "Author",
    name: "Roberto Bolaño",
  },
});

Garbage collection

expect(cache.extract()).toEqual({
  ROOT_QUERY: {
    __typename: "Query",
    book: { __ref: "Book:0312429215" },
  },

  "Book:9781451673319": {
    __typename: "Book",
    title: "Fahrenheit 451",
    author: {
      __ref: 'Author:Ray Bradbury',
    },
  },

  "Author:Ray Bradbury": {
    __typename: "Author",
    name: "Ray Bradbury",
  },

The same query can no longer read Fahrenheit 451 or Ray Bradbury




  "Book:0312429215": {
    __typename: "Book",
    title: "2666",
    author: {
      __ref: "Author:Roberto Bolaño"
    },
  },

  "Author:Roberto Bolaño": {
    __typename: "Author",
    name: "Roberto Bolaño",
  },
});

Garbage collection

expect(cache.extract()).toEqual({
  ROOT_QUERY: {
    __typename: "Query",
    book: { __ref: "Book:0312429215" },
  },

  "Book:9781451673319": {
    __typename: "Book",
    title: "Fahrenheit 451",
    author: {
      __ref: 'Author:Ray Bradbury',
    },
  },

  "Author:Ray Bradbury": {
    __typename: "Author",
    name: "Ray Bradbury",
  },

In fact, the old data are now unreachable  unless we know their IDs




  "Book:0312429215": {
    __typename: "Book",
    title: "2666",
    author: {
      __ref: "Author:Roberto Bolaño"
    },
  },

  "Author:Roberto Bolaño": {
    __typename: "Author",
    name: "Roberto Bolaño",
  },
});

Garbage collection

expect(cache.extract()).toEqual({
  ROOT_QUERY: {
    __typename: "Query",
    book: { __ref: "Book:0312429215" },
  },

  "Book:9781451673319": {
    __typename: "Book",
    title: "Fahrenheit 451",
    author: {
      __ref: 'Author:Ray Bradbury',
    },
  },

  "Author:Ray Bradbury": {
    __typename: "Author",
    name: "Ray Bradbury",
  },

What happens when we call cache.gc()?




  "Book:0312429215": {
    __typename: "Book",
    title: "2666",
    author: {
      __ref: "Author:Roberto Bolaño"
    },
  },

  "Author:Roberto Bolaño": {
    __typename: "Author",
    name: "Roberto Bolaño",
  },
});

Garbage collection

expect(cache.extract()).toEqual({
  ROOT_QUERY: {
    __typename: "Query",
    book: { __ref: "Book:0312429215" },
  },

What happens when we call cache.gc()?




  "Book:0312429215": {
    __typename: "Book",
    title: "2666",
    author: {
      __ref: "Author:Roberto Bolaño"
    },
  },

  "Author:Roberto Bolaño": {
    __typename: "Author",
    name: "Roberto Bolaño",
  },
});

Garbage collection

expect(cache.extract()).toEqual({
  ROOT_QUERY: {
    __typename: "Query",
    book: { __ref: "Book:0312429215" },
  },

  "Book:0312429215": {
    __typename: "Book",
    title: "2666",
    author: {
      __ref: "Author:Roberto Bolaño"
    },
  },

  "Author:Roberto Bolaño": {
    __typename: "Author",
    name: "Roberto Bolaño",
  },
});

What happens when we call cache.gc()?

Garbage collection

expect(cache.extract()).toEqual({
  ROOT_QUERY: {
    __typename: "Query",
    book: { __ref: "Book:0312429215" },
  },

  "Book:0312429215": {
    __typename: "Book",
    title: "2666",
    author: {
      __ref: "Author:Roberto Bolaño"
    },
  },

  "Author:Roberto Bolaño": {
    __typename: "Author",
    name: "Roberto Bolaño",
  },
});

Smaller!

  • Calling cache.retain(id) with an ID like "Author:Ray Bradbury" will protect that object from garbage collection
  • Calling cache.release(id) undoes the retainment
  • Top-level IDs like ROOT_QUERY are automatically retained, so you don't usually need to think about retainment

Cache eviction

  • Calling cache.evict(id) immediately removes any object with that ID, regardless of retainment

  • Eviction works in tandem with garbage collection, as any data that was only reachable from an evicted entity can be automatically collected by calling cache.gc()

Declarative cache configuration API

All things considered, it’s better to tell a computer what you want to achieve, in just one place, rather than specifying how to achieve it in lots of different places

Declarative cache configuration API

The insight of declarative programming: declare your intentions as simply as you can, and then trust the computer to find the best way of satisfying them

  • Query fragments can have type conditions

Fragment matching and possibleTypes

  • Query fragments can have type conditions:

Fragment matching and possibleTypes

query {
  all_characters {
    ... on Character {
      name
    }
    ... on Jedi {
      side
    }
    ... on Droid {
      model
    }
  }
}
  • Query fragments can have type conditions

  • Interfaces can be extended by subtypes

  • Union types have member types

  • Also possible: sub-subtypes, unions of interface types, unions of unions

  • Apollo Client knows nothing about these relationships unless told about them

Fragment matching and possibleTypes

query {
  __schema {
    types {
      kind
      name
      possibleTypes {
        name
      }
    }
  }
}

Fragment matching and possibleTypes

Previously, you would dump the output of this schema introspection query into a JSON file:

Fragment matching and possibleTypes

Then wrap the data with an IntrospectionFragmentMatcher:

import {
  InMemoryCache,
  IntrospectionFragmentMatcher,
} from 'apollo-cache-inmemory';

import introspectionQueryResultData from './fragmentTypes.json';

const cache = new InMemoryCache({
  fragmentMatcher: new IntrospectionFragmentMatcher({
    introspectionQueryResultData,
  }),
});
{
  "data": {
    "__schema": {
      "types": [
        {
          "kind": "OBJECT",
          "name": "AddToFilmPlanetsPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AddToFilmSpeciesPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AddToFilmStarshipsPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AddToFilmVehiclesPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AddToPeopleFilmPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AddToPeoplePlanetPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AddToPeopleSpeciesPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AddToPeopleStarshipsPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AddToPeopleVehiclesPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AssetPreviousValues",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "AssetSubscriptionFilter",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "AssetSubscriptionFilterNode",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "AssetSubscriptionPayload",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "CreateAsset",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "CreateFilm",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "CreatePerson",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "CreatePlanet",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "CreateSpecies",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "CreateStarship",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "CreateVehicle",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "FilmPreviousValues",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "FilmSubscriptionFilter",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "FilmSubscriptionFilterNode",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "FilmSubscriptionPayload",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "FilmcharactersPerson",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "FilmplanetsPlanet",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "FilmspeciesSpecies",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "FilmstarshipsStarship",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "FilmvehiclesVehicle",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "InvokeFunctionInput",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "InvokeFunctionPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Mutation",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "PersonPreviousValues",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PersonSubscriptionFilter",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PersonSubscriptionFilterNode",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "PersonSubscriptionPayload",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PersonfilmsFilm",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PersonhomeworldPlanet",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PersonspeciesSpecies",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PersonstarshipsStarship",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PersonvehiclesVehicle",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "PlanetPreviousValues",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PlanetSubscriptionFilter",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PlanetSubscriptionFilterNode",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "PlanetSubscriptionPayload",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PlanetfilmsFilm",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PlanetresidentsPerson",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromFilmPlanetsPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromFilmSpeciesPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromFilmStarshipsPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromFilmVehiclesPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromPeopleFilmPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromPeoplePlanetPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromPeopleSpeciesPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromPeopleStarshipsPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "RemoveFromPeopleVehiclesPayload",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "SpeciesPreviousValues",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "SpeciesSubscriptionFilter",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "SpeciesSubscriptionFilterNode",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "SpeciesSubscriptionPayload",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "SpeciesfilmsFilm",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "SpeciespeoplePerson",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "StarshipPreviousValues",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "StarshipSubscriptionFilter",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "StarshipSubscriptionFilterNode",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "StarshipSubscriptionPayload",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "StarshipfilmsFilm",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "StarshippilotsPerson",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Subscription",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "UpdateAsset",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "UpdateFilm",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "UpdatePerson",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "UpdatePlanet",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "UpdateSpecies",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "UpdateStarship",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "UpdateVehicle",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "VehiclePreviousValues",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "VehicleSubscriptionFilter",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "VehicleSubscriptionFilterNode",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "VehicleSubscriptionPayload",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "VehiclefilmsFilm",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "VehiclepilotsPerson",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "_ModelMutationType",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Asset",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "AssetFilter",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "AssetOrderBy",
          "possibleTypes": null
        },
        {
          "kind": "SCALAR",
          "name": "DateTime",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Film",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "FilmFilter",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "FilmOrderBy",
          "possibleTypes": null
        },
        {
          "kind": "INTERFACE",
          "name": "Node",
          "possibleTypes": [
            {
              "name": "Asset"
            },
            {
              "name": "Film"
            },
            {
              "name": "Person"
            },
            {
              "name": "Planet"
            },
            {
              "name": "Species"
            },
            {
              "name": "Starship"
            },
            {
              "name": "Vehicle"
            }
          ]
        },
        {
          "kind": "ENUM",
          "name": "PERSON_EYE_COLOR",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "PERSON_GENDER",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "PERSON_HAIR_COLOR",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "PERSON_SKIN_COLOR",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Person",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PersonFilter",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "PersonOrderBy",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Planet",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "PlanetFilter",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "PlanetOrderBy",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Query",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "SPECIES_EYE_COLOR",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "SPECIES_HAIR_COLOR",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "SPECIES_SKIN_COLOR",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Species",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "SpeciesFilter",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "SpeciesOrderBy",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Starship",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "StarshipFilter",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "StarshipOrderBy",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "Vehicle",
          "possibleTypes": null
        },
        {
          "kind": "INPUT_OBJECT",
          "name": "VehicleFilter",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "VehicleOrderBy",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "_QueryMeta",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "__Directive",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "__DirectiveLocation",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "__EnumValue",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "__Field",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "__InputValue",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "__Schema",
          "possibleTypes": null
        },
        {
          "kind": "OBJECT",
          "name": "__Type",
          "possibleTypes": null
        },
        {
          "kind": "ENUM",
          "name": "__TypeKind",
          "possibleTypes": null
        },
        {
          "kind": "SCALAR",
          "name": "Boolean",
          "possibleTypes": null
        },
        {
          "kind": "SCALAR",
          "name": "Float",
          "possibleTypes": null
        },
        {
          "kind": "SCALAR",
          "name": "ID",
          "possibleTypes": null
        },
        {
          "kind": "SCALAR",
          "name": "Int",
          "possibleTypes": null
        },
        {
          "kind": "SCALAR",
          "name": "String",
          "possibleTypes": null
        }
      ]
    }
  }
}

Fragment matching and possibleTypes

So much useless information!

const cache = new InMemoryCache({
  possibleTypes: {
    Character: ["Jedi", "Droid"],
    Test: ["PassingTest", "FailingTest", "SkippedTest"],
    Snake: ["Viper", "Python", "Asp"],
    Python: ["BallPython", "ReticulatedPython"],
  },
});

Fragment matching and possibleTypes

Apollo Client 3.0 allows you to provide just the necessary information:

Easy to generate manually or programmatically

Type policies

The standard apollo-cache-inmemory cache implementation promises to normalize your data, to enable efficient cache updates and repeated cache reads. However, not all data benefits from normalization, and the logic for deciding whether separate objects should be normalized together varies across use cases. Future versions of the Apollo Client cache will unify several related features—dataIdFromObject, the @connection directive, and cacheRedirects—so Apollo developers can implement specialized normalization logic, or even disable normalization for certain queries.

As promised in the Apollo Client 2.6 blog post:

Type policies

  • An object passed to the InMemoryCache constructor via the typePolicies option

  • Each key corresponds to a __typename, and each value is a TypePolicy object that provides configuration for that type

  • Configuration is optional, as most types can get by with default behavior

  • Because configuration coincides with cache creation, the rules are enforced for the entire lifetime of the cache

Entity identity

import {
  InMemoryCache,
  defaultDataIdFromObject,
} from 'apollo-cache-inmemory';

const cache = new InMemoryCache({
  dataIdFromObject(object) {
    switch (object.__typename) {
      case 'Product': return `Product:${object.upc}`;
      case 'Person': return `Person:${object.name}:${object.email}`;
      case 'Book': return `Book:${object.title}:${object.author.name}`;
      default: return defaultDataIdFromObject(object);
    }
  },
});

The Apollo Client 2.x way:

By default, the ID will include the __typename and the value of the id or _id field

Entity identity

type Product @key(fields: "upc") {
  upc: String!
}

type Person @key(fields: "name email") {
  name: String!
  email: String!
}

type Book @key(fields: "title author { name }") {
  title: String!
  author: Author!
}

Important note: this is schema syntax, not directly applicable to the client

Entity identity

The Apollo Client 3.0 way:

Behind the scenes, a typical Book ID might look like
'Book:{"title":"Fahrenheit 451","author":{"name":"Ray Bradbury"}}'

import { InMemoryCache } from '@apollo/client';

const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      keyFields: ["upc"],
    },
    Person: {
      keyFields: ["name", "email"],
    },
    Book: {
      keyFields: ["title", "author", ["name"]],
    },
  },
});

Entity identity

What problems does this new API solve?

  • Field names are reliably included along with their values
  • The order of fields is fixed by the keyFields array order, rather than by object property creation order
  • No time is wasted executing logic for unrelated types
  • Not an opaque function like dataIdFromObject, so Apollo Client can warn about missing keyFields
  • Never confused by query field aliasing
  • Configuration can be code-generated from your schema

Not all data needs normalization!

const cache = new InMemoryCache({
  typePolicies: {













  },
});

In the keynote this morning, Matt used the example of search results, which may not benefit from normalization

Not all data needs normalization!

const cache = new InMemoryCache({
  typePolicies: {
    SearchQueryWithResults: {
      // If we want the search results to be normalized and saved,
      // we might use the query string to identify them in the cache.
      keyFields: ["query"],
    },








  },
});

In the keynote this morning, Matt used the example of search results, which may not benefit from normalization

Not all data needs normalization!

const cache = new InMemoryCache({
  typePolicies: {
    SearchQueryWithResults: {
      // If we want the search results to be normalized and saved,
      // we might use the query string to identify them in the cache.
      keyFields: ["query"],
    },

    SearchResult: {
      // However, the individual search result objects might not
      // have meaningful independent identities, even though they
      // have a __typename. We can store them directly within the
      // SearchQueryWithResults object by disabling normalization:
      keyFields: false,
    },
  },
});

Disabling normalization improves colocation of data for objects that do not have stable identities

Field policies

  • Fields are the properties of entity objects, like the title of a Book object

  • However, in GraphQL, query fields can receive arguments, so a field’s value cannot always be uniquely identified by its name

  • In other words, the cache may need to store multiple distinct values for a single field, depending on the arguments

Field identity

query Feed($type: FeedType!, $offset: Int, $limit: Int) {
  feed(type: $type, offset: $offset, limit: $limit) {
    ...FeedEntry
  }
}

By default, the cache stores separate values for each unique combination of arguments, since it has no knowledge of what the arguments mean, or which ones might be important

This default behavior is problematic for arguments like offset and limit, since those arguments should not alter the underlying data, but merely filter it

Field identity

query Feed($type: FeedType!, $offset: Int, $limit: Int) {
  feed(type: $type, offset: $offset, limit: $limit) @connection(
    key: "feed", 
    filter: ["type"]
  ) {
    ...FeedEntry
  }
}

Since Apollo Client 1.6, the recommended solution has been to include a @connection directive in any query that requests the feed field, to specify which arguments are important

Field identity

query Feed($type: FeedType!, $offset: Int, $limit: Int) {
  feed(type: $type, offset: $offset, limit: $limit) @connection(
    key: "feed", 
    filter: ["type"]
  ) {
    ...FeedEntry
  }
}

While this solves the field identity problem, it’s repetitive!

Nothing stops different feed queries from using different @connection directives, or neglecting to include a @connection directive, which can cause duplication and inconsistencies within the cache

Field identity

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ["type"],
        },
      },
    },
  },
});

Apollo Client 3.0 allows specifying key arguments in one place, when you create the InMemoryCache:

Once you provide this information to the cache, you never have to repeat it anywhere else, as it will be uniformly applied to every query that asks for the feed field within a Query object

Reading and merging field values

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ["type"],
        },
      },
    },
  },
});

This configuration means feed data will be stored according to its type, ignoring any other arguments like offset and limit

Thats nice and all, but how does this actually promote consistency or efficiency of the cache?

Reading and merging field values

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ["type"],



        },
      },
    },
  },
});

When you exclude arguments from the field’s identity, you can still use those arguments to implement a custom read function

Reading and merging field values

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ["type"],
          read(feedData, { args }) {
            return feedData.slice(args.offset, args.offset + args.limit);
          },
        },
      },
    },
  },
});

When you exclude arguments from the field’s identity, you can still use those arguments to implement a custom read function

Reading and merging field values

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ["type"],
          read(feedData, { args }) {
            return feedData.slice(args.offset, args.offset + args.limit);
          },



        },
      },
    },
  },
});

A custom merge function controls how new data should be combined with existing data

Reading and merging field values

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ["type"],
          read(feedData, { args }) {
            return feedData.slice(args.offset, args.offset + args.limit);
          },
          merge(existingData, incomingData, { args }) {
            return mergeFeedData(existingData, incomingData, args);
          },
        },
      },
    },
  },
});

A custom merge function controls how new data should be combined with existing data

Reading and merging field values

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {
          keyArgs: ["type"],
          read(feedData, { args }) {
            return feedData.slice(args.offset, args.offset + args.limit);
          },
          merge(existingData, incomingData, { args }) {
            return mergeFeedData(existingData, incomingData, args);
          },
        },
      },
    },
  },
});

This gives you complete control over the internal representation of the field’s value, as long as read and merge cooperate with each other

Pagination revisited

  • Pagination is the pattern of requesting large lists of data in multiple smaller “pages”

  • Currently achieved through a combination of @connection, fetchMore, and updateQuery

  • Apollo Client 3.0 has a new solution for this important but tricky pattern

    • And you’ve already seen all the necessary ingredients!

Pagination revisited

<Query query={BOOKS_QUERY} variables={{
  offset: 0,
  limit: 10,
}} fetchPolicy="cache-and-network">
  {({ data, fetchMore }) => (
    <Library
      entries={data.books || []}
      onLoadMore={() =>
        fetchMore({
          variables: {
            offset: data.books.length
          },
          updateQuery(prev, { fetchMoreResult }) {
            if (!fetchMoreResult) return prev;
            return Object.assign({}, prev, {
              feed: [...prev.books, ...fetchMoreResult.books]
            });
          }
        })
      }
    />
  )}
</Query>

Pagination revisited

<Query query={BOOKS_QUERY} variables={{
  offset: 0,
  limit: 10,
}} fetchPolicy="cache-and-network">
  {({ data, fetchMore }) => (
    <Library
      entries={data.books || []}
      onLoadMore={() =>
        fetchMore({
          variables: {
            offset: data.books.length
          },
          updateQuery(prev, { fetchMoreResult }) {
            if (!fetchMoreResult) return prev;
            return Object.assign({}, prev, {
              feed: [...prev.books, ...fetchMoreResult.books]
            });
          }
        })
      }
    />
  )}
</Query>

This code needs to be repeated anywhere books are consumed in the application

Pagination revisited

<Query query={BOOKS_QUERY} variables={{
  offset: 0,
  limit: 10,
}} fetchPolicy="cache-and-network">
  {({ data, fetchMore }) => (
    <Library
      entries={data.books || []}
      onLoadMore={() =>
        fetchMore({
          variables: {
            offset: data.books.length
          },
          updateQuery(prev, { fetchMoreResult }) {
            if (!fetchMoreResult) return prev;
            return Object.assign({}, prev, {
              feed: [...prev.books, ...fetchMoreResult.books]
            });
          }
        })
      }
    />
  )}
</Query>

Worse, it has to update the whole query result, when all we want to paginate are the books

Pagination revisited

<Query query={BOOKS_QUERY} variables={{
  offset: 0,
  limit: 10,
}} fetchPolicy="cache-and-network">
  {({ data, fetchMore }) => (
    <Library
      entries={data.books || []}
      onLoadMore={() =>
        fetchMore({
          variables: {
            offset: data.books.length
          },
          updateQuery(prev, { fetchMoreResult }) {
            if (!fetchMoreResult) return prev;
            return Object.assign({}, prev, {
              feed: [...prev.books, ...fetchMoreResult.books]
            });
          }
        })
      }
    />
  )}
</Query>

If we wanted the update to do something more complicated than array concatenation, this code would get much uglier

Pagination revisited

<Query query={BOOKS_QUERY} variables={{
  offset: 0,
  limit: 10,
}} fetchPolicy="cache-and-network">
  {({ data, fetchMore }) => (
    <Library
      entries={data.books || []}
      onLoadMore={() =>
        fetchMore({
          variables: {
            offset: data.books.length
          },
          updateQuery(prev, { fetchMoreResult }) {
            if (!fetchMoreResult) return prev;
            return Object.assign({}, prev, {
              feed: [...prev.books, ...fetchMoreResult.books]
            });
          }
        })
      }
    />
  )}
</Query>

With Apollo Client 3.0, you can delete this code, and implement custom a merge function instead

Pagination revisited

<Query query={BOOKS_QUERY} variables={{
  offset: 0,
  limit: 10,
}} fetchPolicy="cache-and-network">
  {({ data, fetchMore }) => (
    <Library
      entries={data.books || []}
      onLoadMore={() =>
        fetchMore({
          variables: {
            offset: data.books.length
          },
        })
      }
    />
  )}
</Query>

With Apollo Client 3.0, you can delete this code, and implement custom a merge function instead

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {









        }
      }
    }
  }
})

What goes in the Query.books field policy?

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field








        }
      }
    }
  }
})

First: let the cache know you want only one copy of the books field within the Query object

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {

          },




        }
      }
    }
  }
})

Now define what happens whenever the cache receives additional books

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {
            return [...(existing || []), ...incoming];
          },




        }
      }
    }
  }
})

Now define what happens whenever the cache receives additional books

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {
            return [...(existing || []), ...incoming];
          },

          read(existing: Book[], { args }) {
            return existing.slice(args.offset, args.offset + args.limit);
          },
        }
      }
    }
  }
})

Provide a complementary read function to specify what happens when we ask for an arbitrary range of books we already have

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {
            return [...(existing || []), ...incoming];
          },

          read(existing: Book[], { args }) {
            return existing.slice(args.offset, args.offset + args.limit);
          },
        }
      }
    }
  }
})

This reimplements the updateQuery function we started with... but it has the same bugs

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {
            return [...(existing || []), ...incoming];
          },

          read(existing: Book[], { args }) {
            return existing && existing.slice(
              args.offset,
              args.offset + args.limit,
            );
          },
        }
      }
    }
  }
})

Reading needs to work when there are no existing books, by returning undefined

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {





          },

          read(existing: Book[], { args }) {
            return existing && existing.slice(
              args.offset,
              args.offset + args.limit,
            );
          },
        }
      }
    }
  }
})

We can also do a better job handling arbitrary args.offset and args.limit values

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {
            const merged = existing ? existing.slice(0) : [];
            for (let i = args.offset; i < args.offset + args.limit; ++i) {
              merged[i] = incoming[i - args.offset];
            }
            return merged;
          },

          read(existing: Book[], { args }) {
            return existing && existing.slice(
              args.offset,
              args.offset + args.limit,
            );
          },
        }
      }
    }
  }
})

Yes, this code is a bit more complicated, but that's the cost of correctness, and you only have to get it right once

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {
            const merged = existing ? existing.slice(0) : [];
            for (let i = args.offset; i < args.offset + args.limit; ++i) {
              merged[i] = incoming[i - args.offset];
            }
            return merged;
          },

          read(existing: Book[], { args }) {
            return existing && existing.slice(
              args.offset,
              args.offset + args.limit,
            );
          },
        }
      }
    }
  }
})

Once per InMemoryCache is an improvement, but what about once… ever, period?

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: {
          keyArgs: false, // Take full control over this field

          merge(existing: Book[], incoming: Book[], { args }) {
            const merged = existing ? existing.slice(0) : [];
            for (let i = args.offset; i < args.offset + args.limit; ++i) {
              merged[i] = incoming[i - args.offset];
            }
            return merged;
          },

          read(existing: Book[], { args }) {
            return existing && existing.slice(
              args.offset,
              args.offset + args.limit,
            );
          },
        }
      }
    }
  }
})

Nothing about this code is specific to books!

Pagination revisited

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: offsetLimitPaginatedField<Book>(),
      }
    }
  }
})

Reusability wins the day!

Pagination revisited

export function offsetLimitPaginatedField<T>() {
  return {
    keyArgs: false,
    merge(existing: T[] | undefined, incoming: T[], { args }) {
      const merged = existing ? existing.slice(0) : [];
      for (let i = args.offset; i < args.offset + args.limit; ++i) {
        merged[i] = incoming[i - args.offset];
      }
      return merged;
    },
    read(existing: T[] | undefined, { args }) {
      return existing && existing.slice(
        args.offset,
        args.offset + args.limit,
      );
    },
  };
}

Pagination policies can be tricky to get right, but the same approach works no matter how fancy you get: cursors, deduplication, sorting, merging, et cetera…

Pagination revisited

import { offsetLimitPaginatedField } from "./helpers/pagination";

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: offsetLimitPaginatedField<Book>(),








      }
    }
  }
})

One more thing…

Pagination revisited

import { offsetLimitPaginatedField } from "./helpers/pagination";

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: offsetLimitPaginatedField<Book>(),
        book: {






        },
      }
    }
  }
})

What if we also have a Query field for reading individual books by their ISBN numbers?

Pagination revisited

import { offsetLimitPaginatedField } from "./helpers/pagination";

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: offsetLimitPaginatedField<Book>(),
        book: {
          read(existing, { args, toReference }) {
            return existing || toReference({
              __typename: 'Book',
              isbn: args.isbn,
            });
          },
        },
      }
    }
  }
})

A read function can intercept this field and return an existing reference from the cache

Similar to the old cacheRedirects API, which has been removed in Apollo Client 3.0

Pagination revisited

import { offsetLimitPaginatedField } from "./helpers/pagination";

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: offsetLimitPaginatedField<Book>(),
        book(existing, { args, toReference }) {
          return existing || toReference({
            __typename: 'Book',
            isbn: args.isbn,
          });
        },
      }
    }
  }
})

This pattern is common enough to have a convenient shorthand syntax

Pagination revisited

import { offsetLimitPaginatedField } from "./helpers/pagination";

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        books: offsetLimitPaginatedField<Book>(),
        book(existing, { args, toReference }) {
          return existing || toReference({
            __typename: 'Book',
            isbn: args.isbn,
          });
        },
      }
    }
  }
})

Field policy functions are good for a lot more than just pagination!

What about local state?

  • If you use an @client field without providing a local state resolver function, the client reads the field from the cache

  • Now that you can define arbitrary field read functions, you may not even need to define a separate resolver function!

    • Could we delete the local state implementation??? 🤑
  • In order to deliver asynchronous results, synchronous read functions need a way to invalidate their own results (TODO)

Underlying technologies

  • Normalized entity objects stored in the EntityCache are treated as immutable data and updated non-destructively

  • Possible thanks to the DeepMerger abstraction, which preserves object identity whenever merged data causes no changes to existing data

    • Within a single write operation, no modified object is shallow-copied more than once
  • Entity references can be lazily computed for garbage collection

  • Trivial to take immutable snapshots of the cache

Getting started

How to try the beta

  • npm install @apollo/client@beta
  • Start using @apollo/client exports rather than the Apollo Client 2.0 packages

  • When you're ready:

    npm remove apollo-client apollo-utilities apollo-cache apollo-cache-inmemory apollo-link apollo-link-http react-apollo @apollo/react graphql-tag

  • Follow the Release 3.0 pull request

Documentation

  • Latest docs can be found on the release-3.0 branch:

  • A work in progress, as always

  • A little less incomplete than ever before, thanks to some crucial new hires on the documentation team:

Stephen Barlow
(@barlow_vo)

Deprecation cheat sheet

Existing feature Status Replacement
fragmentMatcher removed possibleTypes
dataIdFromObject deprecated keyFields
@connection deprecated keyArgs
cacheRedirects removed field read function
updateQuery avoidable field merge function
resetStore avoidable GC, eviction
local state avoidable* field read function
separate packages consolidated @apollo/client

The replacements are superior because they are declarative, non-repetitive, and consistently applied

Let’s do this

Fine-Tuning Apollo Client Caching for Your Data Graph

By Ben Newman

Fine-Tuning Apollo Client Caching for Your Data Graph

  • 3,383
Loading comments...

More from Ben Newman