Apollo Elements Apollo Elements Guides API Blog Toggle darkmode

Mutations: Cache Management

When defining components that issue graphql mutations, you may want to take control over how and when Apollo updates it's local cache. You can do this with the updater property on elements that extend from ApolloMutation

Say we had this mutation in components/blog-post/BlogPost.mutation.graphql:

mutation BlogPostMutation($content: String) {
  postBlogPost(content: $content) {
    content
    datePosted
    summary
    url
  }
}

And this component definition:

<apollo-mutation>
  <template>
    <loading-overlay ?active="{{ loading }}"></loading-overlay>

    <label>New Post <textarea data-variable="content"></textarea></label>

    <button ?hidden="{{ data }}" trigger>Post!</button>

    <article ?hidden="{{ !data }}">
      <strong>Post Succeeded!</strong>
      <p>{{ data.summary }}</p>
    </article>
  </template>
</apollo-mutation>
import type {
  BlogPostMutationData as Data,
  BlogPostMutationVariables as Variables
} from '../../codegen/operations';

import { ApolloMutationMixin } from '@apollo-elements/mixins/apollo-mutation/mixin';

import BlogPostMutation from './BlogPost.mutation.graphql';

const template = document.createElement('template');
template.innerHTML = `
  <loading-overlay></loading-overlay>

  <label>New Post <textarea></textarea></label>

  <button>Post!</button>

  <article>
    <strong>Post Succeeded!</strong>
    <p></p>
  </article>
`;

class BlogPost extends ApolloMutationMixin(HTMLElement)<Data, Variables> {
  mutation = BlogPostMutation;

  @query('textarea') textarea: HTMLTextAreaElement;

  $(selector) { return this.shadowRoot.querySelector(selector); }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.append(template.content.cloneNode(true));
    this.$('textarea').addEventListener('input', this.onInput.bind(this));
    this.$('button').addEventListener('click', () => mutate());
  }

  render() {
    if (this.loading)
      this.$('loading-overlay').setAttribute('active', '');
    else
      this.$('loading-overlay').removeAttribute('active');
    this.$('button').hidden = !!this.data;
    this.$('article').hidden = !this.data;
    this.$('article p').textContent = this.data?.summary;
  }

  onInput(event) {
    const content = event.target.value;
    this.variables = { content };
  }

  onCompleted() {
    this.$('textarea').value = '';
  }
}

customElements.define('blog-post', BlogPost);
import { ApolloMutationController } from '@apollo-elements/core';
import { LitElement, html } from 'lit';

import { BlogPostMutation } from './BlogPost.mutation.graphql';

@customElement('blog-post')
class BlogPost extends LitElement {
  mutation = new ApolloMutationController(this, BlogPostMutation, {
    onCompleted: () => this.textarea.value = '';
  });

  @query('textarea') textarea: HTMLTextAreaElement;

  render() {
    return html`
      <loading-overlay ?active="${this.mutation.loading}"></loading-overlay>

      <label>New Post
        <textarea @input="${this.onInput}"></textarea>
      </label>

      <button ?hidden="${this.mutation.data}" @click="${() => this.mutation.mutate()}">
        Post!
      </button>

      <article ?hidden="${!this.mutation.data}">
        <strong>Post Succeeded!</strong>
        <p>${this.mutation.data?.summary}</p>
      </article>
    `;
  }

  onInput(event) {
    const content = event.target.value;
    this.mutation.variables = { content };
  }
}
import { FASTElement, html, ref, ViewTemplate } from '@microsoft/fast-element';
import { ApolloMutationBehavior } from '@apollo-elements/fast';

import { BlogPostMutation } from './BlogPost.mutation.graphql';

const name = 'blog-post';

const template: ViewTemplate<BlogPost> = html`
  <loading-overlay ?active="${x => x.mutation.loading}"></loading-overlay>

  <fast-text-area ${ref('textarea')} @input="${(x, { event }) => x.onInput(event)}">
    New Post
  </fast-text-area>

  <fast-button ?hidden="${x => x.mutation.data}" @click="${x => x.mutate()}">
    Post!
  </fast-button>

  <article ?hidden="${x => !x.mutation.data}">
    <strong>Post Succeeded!</strong>
    <p>${x => x.mutation.data?.summary}</p>
  </article>
`;
@customElement({ name, template })
class BlogPost extends FASTElement {
  static readonly is = name;

  declare textarea: HTMLTextAreaElement;

  mutation = new ApolloMutationBehavior(this, BlogPostMutation, {
    onCompleted: () => {
      this.textarea.value = '';
    },
  });

  onInput(event) {
    const content = event.target.value;
    this.variables = { content };
  }
}
import { useMutation, useState, component, html } from '@apollo-elements/haunted';

import { BlogPostMutation } from './BlogPost.mutation.graphql';

function BlogPost() {
  const [content, setContent] = useState('');

  const [addBlogPost, { data, loading }] =
    useMutation(BlogPostMutation, {
      onCompleted: () => setContent(''),
    });

  const variables = { content };

  return html`
    <loading-overlay ?active="${loading}"></loading-overlay>

    <label>New Post
      <textarea @input="${e => setContent(e.target.value)}"></textarea>
    </label>

    <button
        ?hidden="${!!data}"
        @click="${() => addBlogPost({ variables })}"
    >Post!</button>

    <article ?hidden="${!data}">
      <strong>Post Succeeded!</strong>
      <p>${data?.summary}</p>
    </article>
  `;
}

customElements.define('blog-post', component(BlogPost));
import { useMutation, useState, c } from '@apollo-elements/atomico';

import { BlogPostMutation } from './BlogPost.mutation.graphql';

function BlogPost() {
  const [content, setContent] = useState('');

  const [addBlogPost, { data, loading }] =
    useMutation(BlogPostMutation, {
      onCompleted: () => setContent(''),
    });

  const variables = { content };

  return (
    <host shadowDom>
      <loading-overlay active={loading}></loading-overlay>
      <label>New Post
        <textarea oninput={e => setContent(e.target.value)}></textarea>
      </label>
      <button
          hidden={!!data}
          onclick={() => addBlogPost({ variables })}
      >Post!</button>
      <article hidden={!data}>
        <strong>Post Succeeded!</strong>
        <p>{data?.summary}</p>
      </article>
    </host>
  );
}

customElements.define('blog-post', c(BlogPost));
import { mutation, define, html } from '@apollo-elements/fast';

import { BlogPostMutation } from './BlogPost.mutation.graphql';

const name = 'blog-post';

function onInput(host, event) {
  const content = event.target.value;
  host.mutation.variables = { content };
}

async function mutate(host) {
  try {
    host.mutation.mutate();
  } finally {
    host.textarea.value = '';
  }
}

define(name, {
  mutation: mutation(BlogPostMutation),
  render: ({ mutation: { loading, data } }) => html`
    <loading-overlay ?active="${loading}"></loading-overlay>

    <label>
      New Post <textarea ${ref('textarea')}
          @input="${onInput}"></textarea>
    </label>

    <button ?hidden="${data}" @click="${mutate}">
      Post!
    </button>

    <article ?hidden="${!data}">
      <strong>Post Succeeded!</strong>
      <p>${data?.summary}</p>
    </article>
  `
});

This will set data on blog-post just fine, but let's say that you had a <blog-snippets> element which shows the latest posts with this query:

query LatestPostsQuery {
  posts(limit: 10) {
    content
    datePosted
    summary
    url
  }
}

Refetch Queries After Mutating

In that case, you could set the refetchQueries property on <blog-post> (either via the DOM, or using the refetch-queries attribute):

<blog-post refetch-queries="LatestPosts"></blog-post>

But that would mean an extra network round-trip that you might not need.

Update the Cache Synchronously

Instead, you can define an updater method on BlogPost which instructs the apollo cache how to handle the results of the BlogPostMutation.

<script>
  document.currentScript.getRootNode()
    .querySelector('apollo-mutation')
    .updater = function updater(cache, result) {
      // 1: Read the cache synchronously to get the current list of posts
      const query = LatestPostsQuery;
      const cached = cache.readQuery({ query: LatestPostsQuery });

      // 2: Calculate the expected result of LatestPostsQuery,
      //    considering the mutation result
      const data = { posts: [result.data.postBlogPost, ...cached.posts] }

      // 3: Perform the cache update by calling `writeQuery`
      cache.writeQuery({ query, data });
    };
</script>
/**
 * update function which reads a cached query result, merges
 * it with the mutation result, and then writes it back to the cache.
 */
updater(
  cache: ApolloCache<NormalizedCacheObject>,
  result: FetchResult<Data>
) {
  // 1: Read the cache synchronously to get the current list of posts
  const query = LatestPostsQuery;
  const cached = cache.readQuery({ query: LatestPostsQuery });

  // 2: Calculate the expected result of LatestPostsQuery,
  //    considering the mutation result
  const data = { posts: [result.data.postBlogPost, ...cached.posts] }

  // 3: Perform the cache update by calling `writeQuery`
  cache.writeQuery({ query, data });
}
mutation = new ApolloMutationController(this, BlogPostMutation, {
  /**
   * update function which reads a cached query result, merges
   * it with the mutation result, and then writes it back to the cache.
   */
  update(
    cache: ApolloCache<NormalizedCacheObject>,
    result: FetchResult<Data>
  ) {
    // 1: Read the cache synchronously to get the current list of posts
    const query = LatestPostsQuery;
    const cached = cache.readQuery({ query: LatestPostsQuery });

    // 2: Calculate the expected result of LatestPostsQuery,
    //    considering the mutation result
    const data = { posts: [result.data.postBlogPost, ...cached.posts] }

    // 3: Perform the cache update by calling `writeQuery`
    cache.writeQuery({ query, data });
  }
})
mutation = new ApolloMutationBehavior(this, BlogPostMutation, {
  /**
   * update function which reads a cached query result, merges
   * it with the mutation result, and then writes it back to the cache.
   */
  update(
    cache: ApolloCache<NormalizedCacheObject>,
    result: FetchResult<Data>
  ) {
    // 1: Read the cache synchronously to get the current list of posts
    const query = LatestPostsQuery;
    const cached = cache.readQuery({ query: LatestPostsQuery });

    // 2: Calculate the expected result of LatestPostsQuery,
    //    considering the mutation result
    const data = { posts: [result.data.postBlogPost, ...cached.posts] }

    // 3: Perform the cache update by calling `writeQuery`
    cache.writeQuery({ query, data });
  }
});
import type { ResultOf } from '@graphql-typed-document-node/core';
/**
 * update function which reads a cached query result, merges
 * it with the mutation result, and then writes it back to the cache.
 */
function update(
  cache: ApolloCache<NormalizedCacheObject>,
  result: FetchResult<ResultOf<typeof BlogPostMutation>>
) {
  // 1: Read the cache synchronously to get the current list of posts
  const query = LatestPostsQuery;
  const cached = cache.readQuery({ query: LatestPostsQuery });

  // 2: Calculate the expected result of LatestPostsQuery,
  //    considering the mutation result
  const data = { posts: [result.data.postBlogPost, ...cached.posts] }

  // 3: Perform the cache update by calling `writeQuery`
  cache.writeQuery({ query, data });
}

function BlogPost() {
  const [content, setContent] = useState('');

  const [addBlogPost, { data, loading }] =
    useMutation(BlogPostMutation, {
      onCompleted: () => setContent(''),
      update,
    });

  return html`...`;
}
import type { ResultOf } from '@graphql-typed-document-node/core';
/**
 * update function which reads a cached query result, merges
 * it with the mutation result, and then writes it back to the cache.
 */
function update(
  cache: ApolloCache<NormalizedCacheObject>,
  result: FetchResult<ResultOf<typeof BlogPostMutation>>
) {
  // 1: Read the cache synchronously to get the current list of posts
  const query = LatestPostsQuery;
  const cached = cache.readQuery({ query: LatestPostsQuery });

  // 2: Calculate the expected result of LatestPostsQuery,
  //    considering the mutation result
  const data = { posts: [result.data.postBlogPost, ...cached.posts] }

  // 3: Perform the cache update by calling `writeQuery`
  cache.writeQuery({ query, data });
}

function BlogPost() {
  const [content, setContent] = useState('');

  const [addBlogPost, { data, loading }] =
    useMutation(BlogPostMutation, {
      onCompleted: () => setContent(''),
      update,
    });

  return <host>...</host>;
}
define('blog-post', {
  mutation: mutation(BlogPostMutation, {
    /**
     * update function which reads a cached query result, merges
     * it with the mutation result, and then writes it back to the cache.
     */
    function update(
      cache: ApolloCache<NormalizedCacheObject>,
      result: FetchResult<Data>
    ) {
      // 1: Read the cache synchronously to get the current list of posts
      const query = LatestPostsQuery;
      const cached = cache.readQuery({ query: LatestPostsQuery });

      // 2: Calculate the expected result of LatestPostsQuery,
      //    considering the mutation result
      const data = { posts: [result.data.postBlogPost, ...cached.posts] }

      // 3: Perform the cache update by calling `writeQuery`
      cache.writeQuery({ query, data });
    }
  }),
})

Optimistic UI

The summary, datePosted, and url fields that BlogPostMutation returns in this example are calculated by the server. If we know what they will be (or can offer a pretty good guess) at the moment we send the mutation, we can "optimistically" update the UI by setting the optimisticResponse property on our element:

<script>
  document.currentScript.getRootNode()
    .querySelector('apollo-mutation')
    .optimisticResponse = variables => ({
      postBlogPost: {
        __typename: 'BlogPost',
        url: '#',
        // implementation left as an exercise to the reader
        summary: summarize(variables.content),
        datePosted: new Date().toISOString(),
        content,
      },
    });
</script>
optimisticResponse = variables => ({
  postBlogPost: {
    __typename: 'BlogPost',
    url: '#',
    // implementation left as an exercise to the reader
    summary: summarize(variables.content),
    datePosted: new Date().toISOString(),
    content,
  },
});
mutation = new ApolloMutationController(this, BlogPostMutation, {
  optimisticResponse: variables => ({
    postBlogPost: {
      __typename: 'BlogPost',
      url: '#',
      // implementation left as an exercise to the reader
      summary: summarize(variables.content),
      datePosted: new Date().toISOString(),
      content,
    },
  }),
});
mutation = new ApolloMutationBehavior(this, BlogPostMutation, {
  optimisticResponse: variables => ({
    postBlogPost: {
      __typename: 'BlogPost',
      url: '#',
      // implementation left as an exercise to the reader
      summary: summarize(variables.content),
      datePosted: new Date().toISOString(),
      content,
    },
  }),
});
function BlogPost() {
  const [datePosted, setDatePosted] = useState(new Date().toISOString());
  const [content, setContent] = useState('');

  const [addBlogPost, { data, loading }] =
    useMutation<Data, Variables>(BlogPostMutation, {
      onCompleted: () => setContent(''),
      update,
      optimisticResponse: variables => ({
        postBlogPost: {
          __typename: 'BlogPost',
          url: '#',
          // implementation left as an exercise to the reader
          summary: summarize(variables.content),
          datePosted,
          content,
        },
      }),
    });

  const variables = { content };

  return html`
    <loading-overlay ?active="${loading}"></loading-overlay>

    <label>New Post textarea @input="${e => setContent(e.target.value)}"</textarea></label>

    <button
        ?hidden="${!!data}"
        @click="${() => {
          setDatePosted(new Date().toISOString());
          addBlogPost({ variables });
        }}"
    >Post!</button>

    <article ?hidden="${!data}">
      <strong>Post Succeeded!</strong>
      <p>${data?.summary}</p>
    </article>
  `;
}
function BlogPost() {
  const [datePosted, setDatePosted] = useState(new Date().toISOString());
  const [content, setContent] = useState('');

  const [addBlogPost, { data, loading }] =
    useMutation<Data, Variables>(BlogPostMutation, {
      onCompleted: () => setContent(''),
      update,
      optimisticResponse: variables => ({
        postBlogPost: {
          __typename: 'BlogPost',
          url: '#',
          // implementation left as an exercise to the reader
          summary: summarize(variables.content),
          datePosted,
          content,
        },
      }),
    });

  const variables = { content };

  return html`
    <loading-overlay active="${loading}"></loading-overlay>

    <label>New Post textarea oninput="${e => setContent(e.target.value)}"</textarea></label>

    <button
        hidden="${!!data}"
        onclick="${() => {
          setDatePosted(new Date().toISOString());
          addBlogPost({ variables });
        }}"
    >Post!</button>

    <article hidden="${!data}">
      <strong>Post Succeeded!</strong>
      <p>${data?.summary}</p>
    </article>
  `;
}
define(name, {
  mutation: mutation(BlogPostMutation, {
    optimisticResponse: variables => ({
      postBlogPost: {
        __typename: 'BlogPost',
        url: '#',
        // implementation left as an exercise to the reader
        summary: summarize(variables.content),
      },
    }),
  }),
});

But what if the mutation fails? Apollo client's cache can roll back optimistic updates if the mutation fails. That way, as soon as the mutation is in flight, the cache will update once with the optimisticResponse, then if the mutation resolves, it will update again with the real data, and if the mutation rejects, it will roll the optimistic update back.