Local State: Advanced Local State
Say your app has 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 Setup - Query Component
type Site {
id: ID
name: String
isInNetwork(networkId: ID): Boolean
}
type Network {
id: ID
name: String
sites: [Site]
}
Let's start by querying for all existing sites
query AllSites {
sites {
id
name
selected @client
}
}
Then we'll define a component all-sites
which fetches and displays the list of sites. The rendered shadow DOM for the component will look 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>
Managing the UI State Locally
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.
const fragment = gql`
fragment siteSelected on Site {
selected @client
}
`;
function onSelectedChanged(event) {
const selectListEl = event.target;
const itemId = selectListEl.selected.itemId;
client.writeFragment({
id: `Site:${itemId}`,
fragment,
data: {
selected: event.detail.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.
mutation CreateNetwork($sites: ID[]!) {
createNetwork(sites: $sites) {
id
name
sites {
id
}
}
}
This mutation requires an input which is a list of site IDs, which we'll get from the cached local state we prepared above.
Sequence diagram showing one-way data flow.
- from Apollo Cache, to all-sites element via AllSitesQuery
- from all-sites element to select-item element via Property Assignment
- then from select-item element back to all-sites element via MouseEvent
- then from all-sites element back to Apollo Cache via writeFragment
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.
function onWillMutate(event) {
event.target.variables = {
sites: allSites
.filter(x => x.selected)
.map(x => x.id); // string[]
}
}
Final Result
<apollo-query id="all-sites">
<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 allSites = document.querySelector('#all-sites');
allSites.extras = {
onSelectedChanged(event) {
const selectListEl = event.target;
const itemId = selectListEl.selected.itemId;
client.writeFragment({
id: `Site:${itemId}`,
fragment,
data: {
selected: event.detail.selected
}
})
},
onWillMutate(event) {
event.target.variables = {
sites: allSites.data.sites
.filter(x => x.selected)
.map(x => x.id);
}
}
}
</script>
import type { WillMutateEvent } from '@apollo-elements/components';
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 = ApolloMutation<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 SitesElement extends ApolloQueryMixin(HTMLElement)<Data, Variables> {
query = SitesQuery;
#data: Data = null;
get data() { return this.#data; }
set data(value: Data) {
this.#data = value;
this.render;
}
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();
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>) {
this.client.writeFragment({
id: `Site:${event.detail.itemId}`,
fragment: gql`
fragment siteSelected on Site {
selected @client
}
`,
data: {
selected: event.detail.selected
}
})
}
onWillMutate(event: WillMutateEvent & { target: CreateNetworkMutator }) {
event.target.variables = {
sites: this.data.sites
.filter(x => x.selected)
.map(x => x.id); // string[]
}
}
}
customElements.define('all-sites', SitesElement);
import type { WillMutateEvent } from '@apollo-elements/components';
import { ApolloQueryController } from '@apollo-elements/core';
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';
import '@apollo-elements/components/apollo-mutation';
interface ItemDetail {
itemId: string;
selected: boolean;
}
type CreateNetworkMutator = ApolloMutation<typeof CreateNetworkMutation>;
@customElement('all-sites')
class SitesElement extends LitElement {
query = new ApolloQueryController(this, SitesQuery);
render() {
const sites = this.query.data?.sites ?? [];
return html`
<select-list>
${sites.map(site => html`
<select-item
item-id="${site.id}"
item-name="${site.name}"
?selected="${site.selected}"
@select="${this.onSelectedChanged}"
></select-item>
`)}
</select-list>
<apollo-mutation
.mutation="${CreateNetworkMutation}"
@will-mutate="${this.onWillMutate}">
<button trigger>Create</button>
</apollo-mutation>
`;
}
onSelectedChanged(event: CustomEvent<ItemDetail>) {
this.query.client.writeFragment({
id: `Site:${event.detail.itemId}`,
fragment: gql`
fragment siteSelected on Site {
selected @client
}
`,
data: {
selected: event.detail.selected
}
})
}
onWillMutate(event: WillMutateEvent & { target: CreateNetworkMutator }) {
if (!this.query.data?.sites) return;
event.target.variables = {
sites: this.query.data.sites
.filter(x => x.selected)
.map(x => x.id); // string[]
}
}
import type { WillMutateEvent } from '@apollo-elements/components';
import { ApolloQuery, customElement, html } from '@apollo-elements/fast';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';
import '@apollo-elements/components/apollo-mutation';
type CreateNetworkMutator = ApolloMutation<typeof CreateNetworkMutation>;
interface ItemDetail {
itemId: string;
selected: boolean;
}
@customElement({
name: 'all-sites',
template: html<SitesElement>`
<select-list>
${x => data.sites.map(site => html<SitesElement>`
<select-item
item-id="${site.id}"
item-name="${site.name}"
?selected="${site.selected}"
@select="${(x, { event }) => x.onSelectedChanged(event)}"
></select-item>
`)}
</select-list>
<apollo-mutation
.mutation="${CreateNetworkMutation}"
@will-mutate="${(x, { event }) => x.onWillMutate(event)}">
<button trigger>Create</button>
</apollo-mutation>
`,
})
class SitesElement extends ApolloQuery<Data, Variables> {
query = SitesQuery;
onSelectedChanged(event: CustomEvent<ItemDetail>) {
this.client.writeFragment({
id: `Site:${event.detail.itemId}`,
fragment: gql`
fragment siteSelected on Site {
selected @client
}
`,
data: {
selected: event.detail.selected
}
})
}
onWillMutate(event: WillMutateEvent & { target: CreateNetworkMutator }) {
event.target.variables = {
sites: this.data.sites
.filter(x => x.selected)
.map(x => x.id); // string[]
}
}
}
import type { ApolloMutationElement, WillMutateEvent } from '@apollo-elements/components';
import { useQuery, component, html } from '@apollo-elements/haunted';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';
import { SitesQuery } from './Sites.query.graphql';
import '@apollo-elements/components/apollo-mutation';
type CreateNetworkMutator = ApolloMutationElement<typeof CreateNetworkMutation>;
interface ItemDetail {
itemId: string;
selected: boolean;
}
function AllSites() {
const { data, client } = useQuery(SitesQuery);
function onSelectedChanged(event: CustomEvent<ItemDetail>) {
client.writeFragment({
id: `Site:${event.detail.itemId}`,
fragment: gql`
fragment siteSelected on Site {
selected @client
}
`,
data: {
selected: event.detail.selected
}
})
}
function onWillMutate(event: WillMutateEvent & { target: CreateNetworkMutator }) {
event.target.variables = {
sites: data.sites
.filter(x => x.selected)
.map(x => x.id); // string[]
}
}
return html`
<select-list>
${data.sites.map(site => html`
<select-item
item-id="${site.id}"
item-name="${site.name}"
?selected="${site.selected}"
@select="${onSelectedChanged}"
></select-item>
`)}
</select-list>
<apollo-mutation
.mutation="${CreateNetworkMutation}"
@will-mutate="${this.onWillMutate}">
<button trigger>Create</button>
</apollo-mutation>
`,
}
customElements.define('all-sites', component(AllSites));
import type { ApolloQueryController } from '@apollo-elements/core';
import type { ApolloMutation } from '@apollo-elements/components';
import type { WillMutateEvent } from '@apollo-elements/components';
import { query, define, html } from '@apollo-elements/hybrids';
import { CreateNetworkMutation } from './CreateNetwork.mutation.graphql';
import '@apollo-elements/components/apollo-mutation';
type CreateNetworkMutator = ApolloMutation<typeof CreateNetworkMutation>;
type QueryElement = HTMLElement & { query: ApolloQueryController<Data, Variables>> };
function onSelectedChanged(
host: QueryElement,
event: CustomEvent<{ itemId: string, selected: boolean }>
) {
host.query.client.writeFragment({
id: `Site:${event.detail.itemId}`,
fragment: gql`
fragment siteSelected on Site {
selected @client
}
`,
data: {
selected: event.detail.selected
}
})
}
function onWillMutate(
host: QueryElement,
event: WillMutateEvent & { target: CreateNetworkMutator }
) {
event.target.variables = {
sites: host.data.sites
.filter(x => x.selected)
.map(x => x.id); // string[]
}
}
define('all-sites', {
query: query<Data, Variables>(SitesQuery),
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>
<apollo-mutation
.mutation="${CreateNetworkMutation}"
@will-mutate="${onWillMutate}">
<button trigger>Create</button>
</apollo-mutation>
`,
});
Update Mutation Component
This is great for the /create-network
page, but things get tricker when we want to implement the updateNetwork
mutation on page /update-network/:networkId
. 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. Then, if they select C and D on /update-network/:networkId
, only to 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
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>
.