GraphQL-Clients mit React und Apollo, Teil 2
Seite 4: Aktualisieren durch Server Events
Je nach Anwendungsfall ist es erforderlich, dass der Client die Daten nach einem Event automatisch aktualisieren soll, sobald sie sich auf ihm geändert haben. Das passende Beispiel in der BeerAdivsor-Anwendung dazu ist die Einzelansicht eines Biers.
Sie stellt unter anderem die Bewertungen zu einem Bier dar. Sobald eine neue Bewertung für das angezeigte Bier auf dem Server eingeht, sehen alle Nutzer sie sofort, wenn sie die Einzelansicht im Browser geöffnet haben. Voraussetzung für die Option ist, dass die GraphQL API auf dem Server eine Subscription zur Verfügung stellt, an der der Client sich anmelden kann, um Informationen zu neuen Daten zu erhalten. Entwickler können die Subscription mit der subscribeToMore-Property an der Query-Komponente angeben. Sie kann neben der eigentlichen Subscription eine updateQuery-Funktion erhalten, um Daten, die über die Subscription reinkommen, direkt in den Apollo-Cache zu schreiben. Das ist insbesondere notwendig, wenn die Subscription neue Daten liefert (im Gegensatz zu Veränderungen bereits geladener Daten).
Das folgende Beispiel zeigt den entsprechenden Ausschnitt aus der Beer-Seite. Die BEER_PAGE_QUERY liefert alle Daten für das angezeigte Bier (Name, Preis etc.) sowie alle Bewertungen. Die RATING_SUBSCRIPTION, die die Anzeige aktualisiert, liefert hingegen nur Bewertungen vom Server. Die updateQuery-Funktion fügt deswegen dem Bier im Cache die neue Bewertung hinzu.
const BEER_PAGE_QUERY = gql`
query BeerPageQuery($beerId: ID!) {
beer(beerId: $beerId) {
id name price
ratings {
id stars comment
author { name }
}
}
}
`;
const RATING_SUBSCRIPTION = gql`
subscription RatingSubscription($beerId: ID!) {
rating: newRatings(beerId: $beerId) {
id stars comment
author { name }
beer { id }
}
}
`;
function BeerPage(props) {
return (
<Query query={BEER_PAGE_QUERY} variables={{ beerId }}>
{({ loading, error, data, subscribeToMore }) => {
if (loading) { . . . }
if (error) { . . . }
return (
<Beer
beer={data.beer}
subscribeToNewData={() =>
subscribeToMore({
document: RATING_SUBSCRIPTION,
variables: { beerId },
updateQuery: (prev, { subscriptionData }) => {
// Add received Rating to the Beer that is
// already in the Apollo Cache
const newRating = subscriptionData.data.rating;
return {
beer: {
...prev.beer,
ratings: [...prev.beer.ratings, newRating]
}
};
}
})
}
/>
);
}}
</Query>
);
}
Die Beispiel-Komponente reicht die subscribeToMore-Funktion an die Beer-Komponente weiter, die für die Darstellung der Bewertungen zuständig ist und sich demnach für neue Bewertungen interessiert. Die Beer-Komponente startet dann die Subscription sobald sie gerendert ist (componentDidMount) und beendet sie wieder, sobald die Komponente aus dem DOM wieder entfernt wird (componentWillUnmount). Das sorgt dafür, dass Serverzugriffe nur erfolgen, wenn die Komponente tatsächlich sichtbar ist und die Daten wirklich benötigt werden.
Schreiben von Daten
Über eine GraphQL-Schnittstelle lassen sich nicht nur Daten lesen, Mutations können sie ebenfalls verändern. Analog zur vorgestellten Query-Komponente gibt es die Mutation-Komponente, die ebenfalls nach dem "Function as a Child"-Pattern implementiert ist. Sie erwartet die auszuführende Mutation ebenfalls als Property. Die Mutation wird allerdings nicht unmittelbar nach dem Rendern der Komponente ausgeführt. Stattdessen bekommt die Child-Komponente eine Callback-Funktion übergeben, mit der die Mutation gestartet werden kann. Somit kann die Komponente die Mutation ausführen, sobald es fachlich passt, zum Beispiel bei einem Klick auf einen "Speichern"-Knopf.
Im Beispiel können Nutzer über ein Formular neue Bewertungen für ein Bier eintragen. Eine Mutation schickt die erfasste Bewertung (bestehend aus User-Id, Kommentar und Bewertung in Sternen) an den Server, sobald der Benutzer auf "Bewertung abgeben" im Formular klickt. Die Komponente sieht vereinfacht wie folgt aus:
const ADD_RATING_MUTATION = gql`
mutation AddRatingMutation($input: AddRatingInput!) {
newRating: addRating(ratingInput: $input) {
id comment stars author { name }
}
}
`;
function Beer(props) {
return <div>
// . . .
<Mutation mutation={ADD_RATING_MUTATION}>
{addNewRating => {
<AddRatingForm onSubmit={(userId, stars, comment) => {
addNewRating({
variables: {
input: { userId, stars, comment, beerId: props.beerId }
}
})
}} />
}
</Mutation>
</div>;
}
Das Formular zur Eingabe der Bewertung ist in der Komponente AddRatingForm implementiert. Sie erwartet eine Callback-Funktion (onSubmit), die Benutzer durch einen Klick auf "Bewertung abgeben" aufrufen. Die AddRatingForm übergibt der Callback-Funktion die im Formular eingegebenen Daten, sodass sie diese über die addNewRating-Funktion an die umschließende Mutation-Komponente weitergeben kann. Die Mutation-Komponente sorgt schließlich dafür, dass die entsprechende Abfrage (die per mutation-Property übergeben wurde) mit den übergebenen Variablen (hier: den Werten aus dem Formular) ausgeführt wird.
Nach der Ausführung der Mutation versucht Apollo mit den zurückgelieferten Daten den Cache zu aktualisieren. Wenn als Rückgabewert der Mutation ein im Cache vorhandenes Objekt vorliegt, aktualisiert die Applikation es im Cache und passt die Darstellung in der UI an. Im Beispiel verändert das kein bestehendes Objekt, sondern erzeugt ein neues.
Ähnlich wie bei der für Queries gezeigten subscribeToMore-Funktion ist deswegen nach der Ausführung der Mutation eine Aktualisierung des Apollo-Caches manuell notwendig. Dazu erhält die Mutation-Komponente noch eine update-Funktion. Sie bekommt die Daten übergeben, die die Mutation als Antwort vom Server erhält. In der Anwendung ist das die neue Bewertung, die der Server in die Datenbank gespeichert hat. Das Rating-Objekt muss dem im Cache befindlichen Beer-Objekt hinzugefügt werden, wie das folgende Listing zeigt. Der Zugriff auf Einträge im Cache kann übrigens ebenfalls mit einem GraphQL-artigen Query erfolgen. Wie in React üblich sind bestehende Objekte als unveränderlich angesehen und neue Objekte ersetzen sie folglich.
function updateCacheWithNewRating(beerId, cache, newRating) {
// Query, um bestehendes Beer-Objekt aus Cache zu lesen
const fragment = gql`
fragment ratings on Beer {
id ratings { id }
}`;
const existingBeer = cache.readFragment({
id: `Beer:${props.beerId}`,
fragment
});
// Beer aus Cache um neue Bewertung erweitern
const newBeer = {
...existingBeer,
ratings: [...existingBeer.ratings newRating]
}
// Verändertes Beer-Objekt in den Cache schreiben
cache.writeFragment({
id: `Beer:${props.beerId}`,
fragment,
data: newBeer
});
}
function Beer(props) {
return <div>
// . . .
<Mutation mutation={ADD_RATING_MUTATION}>
update={(cache, result) => updateCacheWithNewRating
(props.beerId, cache, result.data.newRating)} >
// siehe oben
</Mutation>
</div>;
}
Durch das Verfahren ist sichergestellt, dass der Client nach Ausführung einer Mutation die neusten Daten hat und sie konsistent in allen Bereichen der Anwendung darstellen kann, ohne die jeweiligen Queries neu ausführen zu müssen. Sofern die manuelle Verwaltung des Caches zu aufwändig ist, ist es ebenfalls möglich, über eine Property anzugeben, welche Queries Apollo nach der Ausführung der Mutation neu ausführen muss, um den Cache zu aktualisieren.
Wenn die Ausführung der Mutation mit hoher Wahrscheinlichkeit erfolgreich ist beziehungsweise das Ergebnis der Mutation sicher vorhersagbar ist, können Entwickler das Verfahren weiter optimieren. Dazu müssen sie der Funktion, die von der Mutation-Komponente an deren Child-Komponente übergeben wurde, beim Aufruf noch einen zweiten Parameter übergeben (im Beispiel der addNewRating-Funktion).
Der erste Parameter übergibt die Daten, die zum Server gelangen sollen. Mit dem zweiten Parameter (optimisticResponse) kann man ein Objekt übergeben, dass der erwarteten Antwort entspricht. Dieses Objekt verwendet die Mutation-Komponente, um während der Ausführung der Mutation den Cache und damit die Darstellung aktualisieren zu können, ohne auf das Ergebnis der Mutation vom Server zu warten. Der Benutzer erhält somit unmittelbares Feedback.
Weicht das später vom Server erhaltene Ergebnis von der optimisticResponse ab, aktualisiert die Anwendung den Cache und damit die UI ein zweites Mal, sodass danach in jedem Fall die korrekten Daten vorliegen. Da im Beispiel neue Objekte beziehungsweise Entitäten (Bewertungen) auf dem Server angelegt werden, deren IDs beim Absenden der Mutation nicht bekannt sind, scheidet die Variante allerdings aus.