Apollo Elements Apollo Elements Guides API Blog Toggle darkmode

Local State: Advanced Local State

Say your app deals with Networks and Sites. Each network has a list of sites which belongs to it, so you define the field isInNetwork on Site which takes a network ID as a field argument. You want to develop CRUD operations for networks, and have those operations relate to the list of sites as well.

The Plan - One Way Data Flow

We'll develop an Apollo element which queries the entire list of sites and displays them in a multi-select dropdown. Clicking a button will issue a mutation to create a new network with the selected sites.

The mutation will take a list of string site IDs as its input. In order to get that list of selected sites, we'll define a selected client-side state property on the Site object. Whenever the user selects a site in the list, we'll update the local state using writeFragment, then when the user clicks the Create Network button, we'll filter the list of all sites, taking only the ids of those sites which are selected.

Sequence Diagram Apollo Cache <create-network> <select-item> AllSitesQuery Property Assignment Select Event writeFragment loop [One-Way Data Flow] Apollo Cache <create-network> <select-item>

Sequence diagram showing one-way data flow.

  1. from Apollo Cache, to create-network element via AllSitesQuery
  2. from create-network element to select-item element via Property Assignment
  3. then from select-item element back to create-network element via MouseEvent
  4. then from create-network element back to Apollo Cache via writeFragment

Let's define a schema for our app. We'll need Site and Network types, as well as their associated operations.

schema.graphql
type Site {
  id: ID
  name: String
  isInNetwork(networkId: ID): Boolean
}

type Network {
  id: ID
  name: String
  sites: [Site]
}

type Query {
  sites: [Site]
}

type Mutation {
  createNetwork(sites: [ID]!): Network
}

The rest of this page assumes we're working in a project that uses GraphQL code generator to convert .graphql files to TypeScript sources.

We'll also need a query document for the list of sites and a mutation document for the "create network" operation.

Sites.query.graphql
query Sites {
  sites {
    id
    name
    selected @client
  }
}
CreateNetwork.mutation.graphql
mutation CreateNetwork($sites: ID[]!) {
  createNetwork(sites: $sites) {
    id
    name
    sites {
      id
    }
  }
}

Then we'll define a component create-network which fetches and displays the list of sites. The rendered shadow DOM for the component will look something like this, using a hypothetical <select-list> element:

<select-list>
  <select-item item-id="1" item-name="Site 1" selected></select-item>
  <select-item item-id="2" item-name="Site 2"></select-item>
  <select-item item-id="3" item-name="Site 3"></select-item>
</select-list>

The <select-list> element (hypothetically) fires a select event whenever the selected item changes, so we'll attach a listener to keep each site's local state in sync. When our user clicks on the checkboxes in the list of <select-item>s, we'll update that Site's client-side selected @client field, which in turn will be read to determine whether a site's corresponding <select-item> component will be marked selected.

selectedSite.fragment.graphql
fragment siteSelected on Site {
  selected @client
}

Our UI component will use that fragment to update the specific sites selected.

Create Mutation Component

To create the Network, the user selects some Sites and then clicks a button which issues the createNetwork mutation, so let's implement that mutation now.

This mutation requires an input which is a list of site IDs, which we'll get from the cached local state we prepared above.

Then, when the user is ready to create the Network, she clicks the Create button, and the component issues the mutation over the network with variables based on the currently selected sites.

<apollo-query id="create-network">
  <script type="application/graphql">
    query AllSites {
      sites {
        id
        name
        selected @client
      }
    }
  </script>
  <template>
    <select-list @change="{{ onSelectedChanged }}">
      <template type="repeat" repeat="{{ data.sites }}">
        <select-item
            item-id="{{ item.id }}"
            item-name="{{ item.name }}"
            ?selected="{{ item.selected }}"
        ></select-item>
      </template>
    </select-list>

    <apollo-mutation @will-mutate="{{ onWillMutate }}">
      <script type="application/graphql">
        mutation CreateNetworkMutation($sites: Site[]) {
          createNetwork(sites: $sites)
        }
      </script>
      <button trigger>Create</button>
    </apollo-mutation>
  </template>
</apollo-query>

<script type="module">
  const createNetwork = document.querySelector('#create-network');

  createNetwork.onSelectedChanged = function onSelectedChanged(event) {
    const selectListEl = event.target;
    const itemId = selectListEl.selected.itemId;
    client.writeFragment({
      id: `Site:${itemId}`,
      fragment,
      data: {
        selected: event.detail.selected
      },
    });
  };

  createNetwork.onWillMutate = function onWillMutate(event) {
    const sites = createNetwork.data.sites
      .filter(x => x.selected)
      .map(x => x.id);
    event.target.variables = { sites };
  };
</script>

import type { WillMutateEvent } from '@apollo-elements/components/events';
import type { ResultOf } from '@graphql-typed-document-node/core';
import type { ApolloMutationElement } from '@apollo-elements/components/apollo-mutation';
import type { SelectItem } from '../components/select';

import { gql } from '@apollo/client/core';

import { selectedSite } from './selectedSite.fragment.graphql';
import { SitesQuery } from './Sites.query.graphql';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';

import { ApolloQueryMixin } from '@apollo-elements/mixins/apollo-query-mixin';

import '@apollo-elements/components/apollo-mutation';

interface ItemDetail {
  itemId: string;
  selected: boolean;
}

type CreateNetworkMutator = ApolloMutationElement<typeof CreateNetworkMutation>;

const template = document.createElement('template');
template.innerHTML = `
  <select-list></select-list>
  <apollo-mutation>
    <script type="application/graphql">
      mutation CreateNetworkMutation($sites: Site[]) {
        createNetwork(sites: $sites)
      }
    </script>
  </apollo-mutation>
`;

const itemTemplate = document.createElement('template');
itemTemplate.innerHTML = '<select-item></select-item>';

class CreateNetworkElement extends ApolloQueryMixin(HTMLElement)<typeof SitesQuery> {
  query = SitesQuery;

  @renders data: ResultOf<typeof SitesQuery> = null;

  constructor() {
    super();
    this
      .attachShadow({ mode: 'open' })
      .append(template.content.cloneNode());
    this
      .shadowRoot
      .querySelector('apollo-mutation')
      .addEventListener('will-mutate', this.onWillMutate.bind(this));
  }

  render() {
    const sites = this.data.sites ?? [];
    sites.forEach(site => {
      const existing = this.shadowRoot.querySelector(`[item-id="${site.id}"]`);
      if (existing) {
        if (site.selected)
          existing.setAttribute('selected', '');
        else
          existing.removeAttribute('selected');
      } else {
        const item = itemTemplate.content.cloneNode() as SelectItem;
        item.setAttribute('item-id', site.id);
        item.setAttribute('item-name', site.name);
        item.addEventListener('select', this.onSelectedChanged.bind(this));
        this.shadowRoot.querySelector('select-list').append(item);
      }
    });
  }

  onSelectedChanged(event: CustomEvent<ItemDetail>) {
    const { itemId, selected } = event.detail;
    this.client.writeFragment({
      id: `Site:${itemId}`,
      fragment: selectedSite,
      data: { selected },
    });
  }

  onWillMutate(event: WillMutateEvent & { target: CreateNetworkMutator }) {
    const sites = this.data.sites
      .filter(x => x.selected)
      .map(x => x.id); // string[]
    event.target.variables = { sites };
  }
}

customElements.define('create-network', CreateNetworkElement);

function renders(proto: CreateNetworkElement, key: string) {
  Object.defineProperty(proto, key, {
    get() {
      return this[`__${key}`];
    },
    set(value) {
      this[`__${key}`] = value;
      this.render();
    }
  })
}

import type { Select } from '@material/mwc-select';

import { ApolloQueryController, ApolloMutationController } from '@apollo-elements/core';
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

import { SitesQuery } from './Sites.query.graphql';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';
import { selectedSite } from './selectedSite.fragment.graphql';

@customElement('create-network')
class CreateNetworkElement extends LitElement {
  query = new ApolloQueryController(this, SitesQuery);

  mutation = new ApolloMutationController(this, CreateNetworkMutation);

  render() {
    const sites = this.query.data?.sites ?? [];
    return html`
      <mwc-menu open multi @selected="${this.onSelectedChanged}">${sites.map(site => html`
        <mwc-list-item value="${site.id}" ?selected="${site.selected}" graphic="icon">
          <mwc-icon slot="graphic" ?hidden="${!site.selected}">check</mwc-icon>
          <span>${site.name}</span>
        </mwc-list-item>`)}
      </mwc-menu>

      <button @click="${this.onClick}">Create</button>
    `;
  }

  onSelectedChanged(event: CustomEvent<{ diff: { added: number[]; removed: number[]} }>) {
    for (const index of event.detail.diff.added)
      this.updateItem(index, true);
    for (const index of event.detail.diff.removed)
      this.updateItem(index, false);
  }

  updateItem(index: number, selected: boolean) {
    this.query.client.writeFragment({
      id: `Site:${this.query.data.sites[index]}`,
      data: { selected },
      fragment: selectedSite,
    })
  }

  onClick() {
    if (!this.query.data?.sites) return;
    const sites = this.query.data.sites
      .filter(x => x.selected)
      .map(x => x.id); // string[]
    this.mutation.mutate({ variables: { sites } });
  }
}

import type { Binding, ViewTemplate } from '@microsoft/fast-element';
import type { Select } from '@microsoft/fast-components';
import type { ResultOf } from '@graphql-typed-document-node/core';
       type Site = ResultOf<typeof SitesQuery>['sites'][number];

import { FASTElement, customElement, html, repeat } from '@microsoft/fast-element';
import { ApolloMutationBehavior, ApolloQueryBehavior } from '@apollo-elements/fast';

import { selectedSite } from './selectedSite.fragment.graphql';
import { SitesQuery } from './Sites.query.graphql';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';

const onClick: Binding<CreateNetworkElement> = x => x.onClick();
const onChange: Binding<CreateNetworkElement> = (x, { event }) => x.onSelectedChanged(event);

const template: ViewTemplate<CreateNetworkElement> = html`
  <fast-select @change="${onChange}">${repeat(x => x.query.data.sites, html`
    <fast-option value="${s => s.id}">${s => s.name}</fast-option>` as ViewTemplate<Site>)}
  </fast-select>
  <fast-button @click="${onClick}">Create</fast-button>
`;

@customElement({ name: 'create-network', template })
class CreateNetworkElement extends FASTElement {
  query = new ApolloQueryBehavior(this, SitesQuery);

  mutation = new ApolloMutationBehavior(this, CreateNetworkMutation);

  onSelectedChanged(event: Event) {
    const target = event.target as Select;
    const [{ selected }] = target.selectedOptions;
    this.query.client.writeFragment({
      id: `Site:${target.value}`,
      data: { selected },
      fragment: selectedSite,
    })
  }

  onClick() {
    this.mutation.variables = {
      sites: this.query.data.sites
        .filter(x => x.selected)
        .map(x => x.id), // string[]
    }
  }
}

import type { ApolloClient, NormalizedCacheObject } from '@apollo/client/core';

import { useQuery, useMutation, component, html } from '@apollo-elements/haunted';

import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';
import { SitesQuery } from './Sites.query.graphql';
import { selectedSite } from './selectedSite.fragment.graphql';

interface ItemDetail {
  itemId: string;
  selected: boolean;
}

function onSelectedChanged(
  client: ApolloClient<NormalizedCacheObject>,
  event: CustomEvent<ItemDetail>
) {
  client.writeFragment({
    id: `Site:${event.detail.itemId}`,
    fragment: selectedSite,
    data: {
      selected: event.detail.selected
    }
  })
}

function CreateNetwork() {
  const { data, client } = useQuery(SitesQuery);
  const [mutate] = useMutation(CreateNetworkMutation);
  const onClick = () => mutate({
    variables: {
      sites: data.sites
        .filter(x => x.selected)
        .map(x => x.id)
    }
  });

  return html`
    <select-list>${data.sites.map(site => html`
      <select-item
          item-id="${site.id}"
          item-name="${site.name}"
          ?selected="${site.selected}"
          @select="${onSelectedChanged.bind(null, client)}"
      ></select-item>`)}
    </select-list>

    <button @click="${onClick}">Create</button>
  `;
}

customElements.define('create-network', component(CreateNetwork));

import type { ApolloClient, NormalizedCacheObject } from '@apollo/client/core';

import { useQuery, useMutation, c } from '@apollo-elements/atomico';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';
import { SitesQuery } from './Sites.query.graphql';
import { selectedSite } from './selectedSite.fragment.graphql';

interface ItemDetail {
  itemId: string;
  selected: boolean;
}

function onSelectedChanged(
  client: ApolloClient<NormalizedCacheObject>,
  event: CustomEvent<ItemDetail>
) {
  client.writeFragment({
    id: `Site:${event.detail.itemId}`,
    fragment: selectedSite,
    data: {
      selected: event.detail.selected
    }
  })
}

function CreateNetwork() {
  const { data, client } = useQuery(SitesQuery);
  const [mutate] = useMutation(CreateNetworkMutation);
  const onClick = () => mutate({
    variables: {
      sites: data.sites
        .filter(x => x.selected)
        .map(x => x.id)
    }
  });

  return (
    <host shadowDom>
      <select-list>{data.sites.map(site => (
        <select-item
            item-id={site.id}
            item-name={site.name}
            selected={site.selected}
            onselect={onSelectedChanged.bind(null, client)}
        ></select-item>))}
      </select-list>
      <button onClick={onClick}>Create</button>
    </host>
  );
}

customElements.define('create-network', c(CreateNetwork));

import type { ApolloQueryController, ApolloMutationController } from '@apollo-elements/core';

import { query, mutation, define, html } from '@apollo-elements/hybrids';

import { SitesQuery } from './Sites.query.graphql';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';
import { selectedSite } from './selectedSite.fragment.graphql';

interface CreateNetworkElement extends HTMLElement {
  query: ApolloQueryController<typeof SitesQuery>
  mutation: ApolloMutationController<typeof CreateNetworkMutation>
};

function onSelectedChanged(
  host: CreateNetworkElement,
  event: CustomEvent<{ itemId: string, selected: boolean }>
) {
  host.query.client.writeFragment({
    id: `Site:${event.detail.itemId}`,
    fragment: selectedSite,
    data: {
      selected: event.detail.selected
    }
  })
}

function onClick(
  host: CreateNetworkElement,
  event: MouseEvent & { target: CreateNetworkElement },
) {
  const sites = host.query.data.sites
    .filter(x => x.selected)
    .map(x => x.id); // string[]
  event.target.mutation.mutate({ variables: { sites } });
}

define<CreateNetworkElement>('create-network', {
  query: query(SitesQuery),
  mutation: mutation(CreateNetworkMutation),
  render: ({ query: { data } }) => html`
    <select-list>${(data?.sites??[]).map(site => html`
      <select-item
          item-id="${site.id}"
          item-name="${site.name}"
          selected="${site.selected}"
          onselect="${onSelectedChanged}"
      ></select-item>`)}
    </select-list>

    <button onclick="${onClick}">Create</button>
  `,
});

Update Network Component

This is great for the /create-network page, but now we want to implement an 'update network' mutation component at a update-network/:networkId route. Now we have to show the same <select-list> of Sites, but the selected property of each one has to relate only to the specific page the user is viewing it on.

In other words, if a user loads up /create-network, selects sites A and B, then loads up /update-network/:networkId, they shouldn't see A and B selected on that page, since they're on the page of a specific site. Then, if they select C and D on /update-network/:networkId then return to /create-network, they should only see A and B selected, not C and D.

To do this, let's define the <update-network-page>'s query to pass a networkId argument to the client-side selected field

UpdateNetwork.mutation.graphql
query UpdateNetworkPageQuery($networkId: ID!) {
  location @client {
    params {
      networkId @export(as: "networkId")
    }
  }

  sites {
    id
    name
    isInNetwork(networkId: $networkId)
    selected(networkId: $networkId)
  }

  network(networkId: $networkId) {
    id
    name
  }
}

This query lets us combine a view of all Sites with their relationship to a particular Network.

The Type Policies

Let's define a FieldPolicy for Site's selected field which lets us handle both cases: the create page and the update page

const typePolicies: TypePolicies = {
  Site: {
    fields: {
      selected: {
        keyArgs: ['networkId'],
        read(prev, { args, storage, readField }) {
          if (!args?.networkId)
            return prev ?? true;
          else {
            return storage[args.networkId] ?? readField({
              typename: 'Site',
              fieldName: 'isInNetwork',
              args: { networkId: args.networkId }
            });
          }
        },
        merge(_, next, { args, storage }) {
          if (args?.networkId)
            storage[args.networkId] = next;
          return next;
        },
      }
    }
  }
}

With this type policy, any time the selected field gets accessed without any args, or with no networkId arg, the caller gets the previous known value - a boolean flag on the site object. But if the field is queried with a networkId arg, as in the update-network page, instead of returning the 'global' value (prev), it will return the value stored at storage[args.networkId], which is a Record<string, boolean>.