Skip to content

Latest commit

 

History

History
802 lines (686 loc) · 33.8 KB

File metadata and controls

802 lines (686 loc) · 33.8 KB

Step 10: Live updates with GraphQL subscriptions

So far we've been developing the app and we've been treating it as if there's no other users; we're the only one exists. This approach is true when we want to develop a UI and focus on UX, but comes a point where we need to start thinking on a macro level. Our app is social interactive, and if things work properly for me, it doesn't mean that it works properly to the fellow I'm chatting with. It's inevitable to have an authentication system in our app, hence we need to take care of things before we get to that stage.

Try to open 2 instances of the app in 2 separate tabs/windows, and navigate into the same chat room. Try to send a message with one instance and notice that the second instance doesn't update unless we refresh the page.

ezgif com-video-to-gif (2)

This issue is very important and should be addressed, because a chat is all about sending and receiving messages on a lively basis. This issue was expected, as there's no mechanism that would trigger and listen to changes in the back-end. In this chapter we're gonna address that issue by implementing exactly that mechanism.

Introducing: GraphQL Subscriptions

GraphQL subscriptions is a mechanism that works on web-sockets and live communication; clients can subscribe to it and be notified regards specific changes that happen in the back-end. Notifications will be triggered manually by us and can be provided with parameters that provide additional information regards the triggered event. For example, a messageAdded will be published with the new message, and will notify all clients who are subscribed to that event. Once the subscribers are notified, they can respond as they would like to, such as updating the UI.

subscription-notifications

A subscription is presented in our GraphQL schema as a separate type called Subscription, where each field represents an event name along with its return type. Like any other GraphQL type, each field should be match with a resolver where we handle the request.

In this chapter we will implement the messageAdded subscription, so users can be notified when it happens and update the messages list to contain the new message.

Implementing a subscription

We will start by creating a new Subscription type in our GraphQL schema with the field messageAdded:

Changed schema/typeDefs.graphql
@@ -23,3 +23,7 @@
 ┊23┊23┊type Mutation {
 ┊24┊24┊  addMessage(chatId: ID!, content: String!): Message
 ┊25┊25┊}
+┊  ┊26┊
+┊  ┊27┊type Subscription {
+┊  ┊28┊  messageAdded: Message!
+┊  ┊29┊}

Changes are triggered using an event-emitter like object called PubSub. This can be done using the PubSub.prototype.publish method. We will create a new instance of it and will provide it via the context - a common pattern for providing objects which are useful for the execution of the resolvers:

TODO: Explain what the context is

Changed index.ts
@@ -1,4 +1,4 @@
-┊1┊ ┊import { ApolloServer, gql } from 'apollo-server-express';
+┊ ┊1┊import { ApolloServer, gql, PubSub } from 'apollo-server-express';
 ┊2┊2┊import cors from 'cors';
 ┊3┊3┊import express from 'express';
 ┊4┊4┊import schema from './schema';
@@ -12,7 +12,11 @@
 ┊12┊12┊  res.send('pong');
 ┊13┊13┊});
 ┊14┊14┊
-┊15┊  ┊const server = new ApolloServer({ schema });
+┊  ┊15┊const pubsub = new PubSub();
+┊  ┊16┊const server = new ApolloServer({
+┊  ┊17┊  schema,
+┊  ┊18┊  context: () => ({ pubsub }),
+┊  ┊19┊});
 ┊16┊20┊
 ┊17┊21┊server.applyMiddleware({
 ┊18┊22┊  app,

Inside the addMessage resolver we will publish a new event called messageAdded. The 3rd argument of the resolver will be the context object that we've just defined in the previous step, where we can use the pubsub instance. The TypeScript type of our context can be directly defined and generated by CodeGen through the codegen.yml file. This can be specified under the ContextType field with the file path that contains the context followed by the name of the exported object, like so:

Changed codegen.yml
@@ -6,6 +6,7 @@
 ┊ 6┊ 6┊      - typescript
 ┊ 7┊ 7┊      - typescript-resolvers
 ┊ 8┊ 8┊    config:
+┊  ┊ 9┊      contextType: ../context#MyContext
 ┊ 9┊10┊      mappers:
 ┊10┊11┊        # import { Message } from '../db'
 ┊11┊12┊        # The root types of Message resolvers
Added context.ts
@@ -0,0 +1,5 @@
+┊ ┊1┊import { PubSub } from 'apollo-server-express';
+┊ ┊2┊
+┊ ┊3┊export type MyContext = {
+┊ ┊4┊  pubsub: PubSub;
+┊ ┊5┊};

The event will be published right after the message was pushed into the messages collection, because order is a crucial thing. We don't want to notify our users unless the change has been made. The event will have a single parameter which represents the new message.

Changed schema/resolvers.ts
@@ -29,7 +29,7 @@
 ┊29┊29┊  },
 ┊30┊30┊
 ┊31┊31┊  Mutation: {
-┊32┊  ┊    addMessage(root, { chatId, content }) {
+┊  ┊32┊    addMessage(root, { chatId, content }, { pubsub }) {
 ┊33┊33┊      const chatIndex = chats.findIndex((c) => c.id === chatId);
 ┊34┊34┊
 ┊35┊35┊      if (chatIndex === -1) return null;
@@ -52,6 +52,10 @@
 ┊52┊52┊      chats.splice(chatIndex, 1);
 ┊53┊53┊      chats.unshift(chat);
 ┊54┊54┊
+┊  ┊55┊      pubsub.publish('messageAdded', {
+┊  ┊56┊        messageAdded: message,
+┊  ┊57┊      });
+┊  ┊58┊
 ┊55┊59┊      return message;
 ┊56┊60┊    },
 ┊57┊61┊  },
Changed tests/mutations/addMessage.test.ts
@@ -1,5 +1,5 @@
 ┊1┊1┊import { createTestClient } from 'apollo-server-testing';
-┊2┊ ┊import { ApolloServer, gql } from 'apollo-server-express';
+┊ ┊2┊import { ApolloServer, PubSub, gql } from 'apollo-server-express';
 ┊3┊3┊import schema from '../../schema';
 ┊4┊4┊import { resetDb } from '../../db';
 ┊5┊5┊
@@ -7,7 +7,10 @@
 ┊ 7┊ 7┊  beforeEach(resetDb);
 ┊ 8┊ 8┊
 ┊ 9┊ 9┊  it('should add message to specified chat', async () => {
-┊10┊  ┊    const server = new ApolloServer({ schema });
+┊  ┊10┊    const server = new ApolloServer({
+┊  ┊11┊      schema,
+┊  ┊12┊      context: () => ({ pubsub: new PubSub() }),
+┊  ┊13┊    });
 ┊11┊14┊
 ┊12┊15┊    const { query, mutate } = createTestClient(server);

A subscription resolver behaves differently and thus should be implemented differently. Using the pubsub.asyncIterator instance, we can specify which events are relevant for the subscription, for example, all clients who are subscribers of the chatUpdated subscription will be notified when messageAdded, messageRemoved and chatInfoChanged events were triggered. For now, we will have a 1 to 1 relationship between the messageAdded event and messageAdded subscription. In code, it should look like this:

Changed schema/resolvers.ts
@@ -59,6 +59,13 @@
 ┊59┊59┊      return message;
 ┊60┊60┊    },
 ┊61┊61┊  },
+┊  ┊62┊
+┊  ┊63┊  Subscription: {
+┊  ┊64┊    messageAdded: {
+┊  ┊65┊      subscribe: (root, args, { pubsub }) =>
+┊  ┊66┊        pubsub.asyncIterator('messageAdded'),
+┊  ┊67┊    },
+┊  ┊68┊  },
 ┊62┊69┊};
 ┊63┊70┊
 ┊64┊71┊export default resolvers;

The idea behind the pubsub.asyncIterator method is that it returns an Iterator like object, where each value is a promise that will be resolved when the relevant events are triggered. By default, the parameter that has a similar name to the subscription will be returned as a response, e.g. messageAdded parameter will be sent back to the subscribers. This behavior can be modified as explained here, but it's very unlikely and not necessary for our use case.

As mentioned at the beginning of this article, there needs to be an open connection between the client and the server so live updates can happen. There are serveral methods for doing so, but the 2 most popular ones are:

  • Based on polling with HTTP protocol
  • Based on web-sockets (WS protocol)

HTTP polling means that each amount of time an HTTP request will be made to the server where potential changes can be sent back to us at any given time. HTTP requests are very reliable, but the problem with them is that they contain a lot of information in their headers, so even if we sent an empty request, it might be still very heavy due to cookies, user-agent, language, request type, etc.

With web-sockets, once a connection has been established, it will remain open and it will only send the information which is relevant for the current session, so it's much faster. The communication between the server and the client is bi-directional when it comes to web-sockets, which means that a user can spontaneously receive information from the server, as long as the communication channel remains open.

More information about the advantages of Web Sockets over HTTP can be found at websocket.org

The subscription mechanism can be installed using the server.installSubscriptionHandlers. It will use the WS protocol by default and will fallback to HTTP polling if there were troubles establishing a connection via WS protocol:

Changed index.ts
@@ -1,6 +1,7 @@
 ┊1┊1┊import { ApolloServer, gql, PubSub } from 'apollo-server-express';
 ┊2┊2┊import cors from 'cors';
 ┊3┊3┊import express from 'express';
+┊ ┊4┊import http from 'http';
 ┊4┊5┊import schema from './schema';
 ┊5┊6┊
 ┊6┊7┊const app = express();
@@ -23,8 +24,11 @@
 ┊23┊24┊  path: '/graphql',
 ┊24┊25┊});
 ┊25┊26┊
+┊  ┊27┊const httpServer = http.createServer(app);
+┊  ┊28┊server.installSubscriptionHandlers(httpServer);
+┊  ┊29┊
 ┊26┊30┊const port = process.env.PORT || 4000;
 ┊27┊31┊
-┊28┊  ┊app.listen(port, () => {
+┊  ┊32┊httpServer.listen(port, () => {
 ┊29┊33┊  console.log(`Server is listening on port ${port}`);
 ┊30┊34┊});

Now we have everything set and we can start listening to subscriptions and react to to triggered changes.

Using subscriptions

To support subscriptions we need to establish a WS connection. For that we will need to update our Apollo client. We will install a couple of packages that will enable such feature:

$ yarn add subscriptions-transport-ws apollo-link apollo-link-ws apollo-utilities
  • subscriptions-transport-ws - a transport layer that understands how client and GraphQL API communicates with each other. The spec has GQL_INIT GQL_UPDATE GQL_DATA events.
  • apollo-link-ws - Will establish a WS connection.
  • apollo-link - Will enable WS and HTTP connections co-exist in a single client.
  • apollo-utilities - Includes utility functions that will help us analyze a GraphQL AST.

The WS url can be composed by simply running a regular expression over the REACT_APP_SERVER_URL environment variable and is unnecessary to be stored separately. Here's how our new client should look like: \

Changed src/client.ts
@@ -1,16 +1,48 @@
 ┊ 1┊ 1┊import { InMemoryCache } from 'apollo-cache-inmemory';
 ┊ 2┊ 2┊import { ApolloClient } from 'apollo-client';
+┊  ┊ 3┊import { getMainDefinition } from 'apollo-utilities';
 ┊ 3┊ 4┊import { HttpLink } from 'apollo-link-http';
+┊  ┊ 5┊import { WebSocketLink } from 'apollo-link-ws';
+┊  ┊ 6┊import { ApolloLink, split } from 'apollo-link';
 ┊ 4┊ 7┊
 ┊ 5┊ 8┊const httpUri = process.env.REACT_APP_SERVER_URL + '/graphql';
+┊  ┊ 9┊const wsUri = httpUri.replace(/^https?/, 'ws');
 ┊ 6┊10┊
 ┊ 7┊11┊const httpLink = new HttpLink({
 ┊ 8┊12┊  uri: httpUri,
 ┊ 9┊13┊});
 ┊10┊14┊
+┊  ┊15┊const wsLink = new WebSocketLink({
+┊  ┊16┊  uri: wsUri,
+┊  ┊17┊  options: {
+┊  ┊18┊    // Automatic reconnect in case of connection error
+┊  ┊19┊    reconnect: true,
+┊  ┊20┊  },
+┊  ┊21┊});
+┊  ┊22┊
+┊  ┊23┊/**
+┊  ┊24┊ * Fix error typing in `split` method in `apollo-link`
+┊  ┊25┊ * Related issue https://github.com/apollographql/apollo-client/issues/3090
+┊  ┊26┊ */
+┊  ┊27┊export interface Definition {
+┊  ┊28┊  kind: string;
+┊  ┊29┊  operation?: string;
+┊  ┊30┊}
+┊  ┊31┊const terminatingLink = split(
+┊  ┊32┊  ({ query }) => {
+┊  ┊33┊    const { kind, operation }: Definition = getMainDefinition(query);
+┊  ┊34┊    // If this is a subscription query, use wsLink, otherwise use httpLink
+┊  ┊35┊    return kind === 'OperationDefinition' && operation === 'subscription';
+┊  ┊36┊  },
+┊  ┊37┊  wsLink,
+┊  ┊38┊  httpLink
+┊  ┊39┊);
+┊  ┊40┊
+┊  ┊41┊const link = ApolloLink.from([terminatingLink]);
+┊  ┊42┊
 ┊11┊43┊const inMemoryCache = new InMemoryCache();
 ┊12┊44┊
 ┊13┊45┊export default new ApolloClient({
-┊14┊  ┊  link: httpLink,
+┊  ┊46┊  link,
 ┊15┊47┊  cache: inMemoryCache,
 ┊16┊48┊});

Our subscription listeners should live globally across our application and shouldn't be bound to a specific component, thus we will create an external service which will be responsible of doing so. Using that service, we will update our GraphQL data-store any time a new message has been added. We will define a messageAdded subscription in a dedicated file under the src/graphql/subscriptions dir where all our subscriptions will be defined and exported:

Added src/graphql/subscriptions/index.ts
@@ -0,0 +1 @@
+┊ ┊1┊export { default as messageAdded } from './messageAdded.subscription';
Added src/graphql/subscriptions/messageAdded.subscription.ts
@@ -0,0 +1,11 @@
+┊  ┊ 1┊import gql from 'graphql-tag';
+┊  ┊ 2┊import * as fragments from '../fragments';
+┊  ┊ 3┊
+┊  ┊ 4┊export default gql`
+┊  ┊ 5┊  subscription MessageAdded {
+┊  ┊ 6┊    messageAdded {
+┊  ┊ 7┊      ...Message
+┊  ┊ 8┊    }
+┊  ┊ 9┊  }
+┊  ┊10┊  ${fragments.message}
+┊  ┊11┊`;

Now we will create the service under the path services/cache.service.ts. Like any other GraphQL operation, @apollo/react-hooks provides us with a dedicated React hook for subscriptions called useSubscription.

Given the subscription document and the onSubscriptionData callback we can handle incoming changes. We will be using GraphQL Code Generator to generate typed subscription hooks, as the typescript-react-apollo plug-in supports it right out of the box.

So let's run the code generation command:

$ yarn codegen

Now we can import and use the newly generated hook useMessageAddedSubscription in the cache.service. Like mentioned earlier, we will be using the onSubscriptionData callback to retrieve the change that was sent by the server and we will use it to re-write our cache. In this case we will be writing a new fragment for the incoming message, and we will update the correlated chat:

Added src/services/cache.service.ts
@@ -0,0 +1,92 @@
+┊  ┊ 1┊import { DataProxy } from 'apollo-cache';
+┊  ┊ 2┊import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+┊  ┊ 3┊import * as fragments from '../graphql/fragments';
+┊  ┊ 4┊import * as queries from '../graphql/queries';
+┊  ┊ 5┊import {
+┊  ┊ 6┊  MessageFragment,
+┊  ┊ 7┊  useMessageAddedSubscription,
+┊  ┊ 8┊  ChatsQuery,
+┊  ┊ 9┊} from '../graphql/types';
+┊  ┊10┊
+┊  ┊11┊type Client = Pick<
+┊  ┊12┊  DataProxy,
+┊  ┊13┊  'readFragment' | 'writeFragment' | 'readQuery' | 'writeQuery'
+┊  ┊14┊>;
+┊  ┊15┊
+┊  ┊16┊export const useCacheService = () => {
+┊  ┊17┊  useMessageAddedSubscription({
+┊  ┊18┊    onSubscriptionData: ({ client, subscriptionData: { data } }) => {
+┊  ┊19┊      if (data) {
+┊  ┊20┊        writeMessage(client, data.messageAdded);
+┊  ┊21┊      }
+┊  ┊22┊    },
+┊  ┊23┊  });
+┊  ┊24┊};
+┊  ┊25┊
+┊  ┊26┊export const writeMessage = (client: Client, message: MessageFragment) => {
+┊  ┊27┊  type FullChat = { [key: string]: any };
+┊  ┊28┊  let fullChat;
+┊  ┊29┊
+┊  ┊30┊  const chatIdFromStore = defaultDataIdFromObject(message.chat);
+┊  ┊31┊
+┊  ┊32┊  if (chatIdFromStore === null) {
+┊  ┊33┊    return;
+┊  ┊34┊  }
+┊  ┊35┊  try {
+┊  ┊36┊    fullChat = client.readFragment<FullChat>({
+┊  ┊37┊      id: chatIdFromStore,
+┊  ┊38┊      fragment: fragments.fullChat,
+┊  ┊39┊      fragmentName: 'FullChat',
+┊  ┊40┊    });
+┊  ┊41┊  } catch (e) {
+┊  ┊42┊    return;
+┊  ┊43┊  }
+┊  ┊44┊
+┊  ┊45┊  if (fullChat === null || fullChat.messages === null) {
+┊  ┊46┊    return;
+┊  ┊47┊  }
+┊  ┊48┊  if (fullChat.messages.some((m: any) => m.id === message.id)) return;
+┊  ┊49┊
+┊  ┊50┊  fullChat.messages.push(message);
+┊  ┊51┊  fullChat.lastMessage = message;
+┊  ┊52┊
+┊  ┊53┊  client.writeFragment({
+┊  ┊54┊    id: chatIdFromStore,
+┊  ┊55┊    fragment: fragments.fullChat,
+┊  ┊56┊    fragmentName: 'FullChat',
+┊  ┊57┊    data: fullChat,
+┊  ┊58┊  });
+┊  ┊59┊
+┊  ┊60┊  let data;
+┊  ┊61┊  try {
+┊  ┊62┊    data = client.readQuery<ChatsQuery>({
+┊  ┊63┊      query: queries.chats,
+┊  ┊64┊    });
+┊  ┊65┊  } catch (e) {
+┊  ┊66┊    return;
+┊  ┊67┊  }
+┊  ┊68┊
+┊  ┊69┊  if (!data || data === null) {
+┊  ┊70┊    return null;
+┊  ┊71┊  }
+┊  ┊72┊  if (!data.chats || data.chats === undefined) {
+┊  ┊73┊    return null;
+┊  ┊74┊  }
+┊  ┊75┊  const chats = data.chats;
+┊  ┊76┊
+┊  ┊77┊  const chatIndex = chats.findIndex((c: any) => {
+┊  ┊78┊    if (message === null || message.chat === null) return -1;
+┊  ┊79┊    return c.id === message?.chat?.id;
+┊  ┊80┊  });
+┊  ┊81┊  if (chatIndex === -1) return;
+┊  ┊82┊  const chatWhereAdded = chats[chatIndex];
+┊  ┊83┊
+┊  ┊84┊  // The chat will appear at the top of the ChatsList component
+┊  ┊85┊  chats.splice(chatIndex, 1);
+┊  ┊86┊  chats.unshift(chatWhereAdded);
+┊  ┊87┊
+┊  ┊88┊  client.writeQuery({
+┊  ┊89┊    query: queries.chats,
+┊  ┊90┊    data: { chats: chats },
+┊  ┊91┊  });
+┊  ┊92┊};

We will also use the exported writeMessage() function in the ChatRoomScreen so we won't have any code duplications:

Changed src/components/ChatRoomScreen/index.tsx
@@ -1,4 +1,3 @@
-┊1┊ ┊import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
 ┊2┊1┊import gql from 'graphql-tag';
 ┊3┊2┊import React from 'react';
 ┊4┊3┊import { useCallback } from 'react';
@@ -7,13 +6,9 @@
 ┊ 7┊ 6┊import MessageInput from './MessageInput';
 ┊ 8┊ 7┊import MessagesList from './MessagesList';
 ┊ 9┊ 8┊import { History } from 'history';
-┊10┊  ┊import {
-┊11┊  ┊  ChatsQuery,
-┊12┊  ┊  useGetChatQuery,
-┊13┊  ┊  useAddMessageMutation,
-┊14┊  ┊} from '../../graphql/types';
-┊15┊  ┊import * as queries from '../../graphql/queries';
+┊  ┊ 9┊import { useGetChatQuery, useAddMessageMutation } from '../../graphql/types';
 ┊16┊10┊import * as fragments from '../../graphql/fragments';
+┊  ┊11┊import { writeMessage } from '../../services/cache.service';
 ┊17┊12┊
 ┊18┊13┊const Container = styled.div`
 ┊19┊14┊  background: url(/assets/chat-background.jpg);
@@ -47,10 +42,6 @@
 ┊47┊42┊  history: History;
 ┊48┊43┊}
 ┊49┊44┊
-┊50┊  ┊interface ChatsResult {
-┊51┊  ┊  chats: any[];
-┊52┊  ┊}
-┊53┊  ┊
 ┊54┊45┊const ChatRoomScreen: React.FC<ChatRoomScreenParams> = ({
 ┊55┊46┊  history,
 ┊56┊47┊  chatId,
@@ -82,73 +73,7 @@
 ┊ 82┊ 73┊        },
 ┊ 83┊ 74┊        update: (client, { data }) => {
 ┊ 84┊ 75┊          if (data && data.addMessage) {
-┊ 85┊   ┊            type FullChat = { [key: string]: any };
-┊ 86┊   ┊            let fullChat;
-┊ 87┊   ┊            const chatIdFromStore = defaultDataIdFromObject(chat);
-┊ 88┊   ┊
-┊ 89┊   ┊            if (chatIdFromStore === null) {
-┊ 90┊   ┊              return;
-┊ 91┊   ┊            }
-┊ 92┊   ┊            try {
-┊ 93┊   ┊              fullChat = client.readFragment<FullChat>({
-┊ 94┊   ┊                id: chatIdFromStore,
-┊ 95┊   ┊                fragment: fragments.fullChat,
-┊ 96┊   ┊                fragmentName: 'FullChat',
-┊ 97┊   ┊              });
-┊ 98┊   ┊            } catch (e) {
-┊ 99┊   ┊              return;
-┊100┊   ┊            }
-┊101┊   ┊
-┊102┊   ┊            if (fullChat === null || fullChat.messages === null) {
-┊103┊   ┊              return;
-┊104┊   ┊            }
-┊105┊   ┊            if (
-┊106┊   ┊              fullChat.messages.some(
-┊107┊   ┊                (currentMessage: any) =>
-┊108┊   ┊                  data.addMessage && currentMessage.id === data.addMessage.id
-┊109┊   ┊              )
-┊110┊   ┊            ) {
-┊111┊   ┊              return;
-┊112┊   ┊            }
-┊113┊   ┊
-┊114┊   ┊            fullChat.messages.push(data.addMessage);
-┊115┊   ┊            fullChat.lastMessage = data.addMessage;
-┊116┊   ┊
-┊117┊   ┊            client.writeFragment({
-┊118┊   ┊              id: chatIdFromStore,
-┊119┊   ┊              fragment: fragments.fullChat,
-┊120┊   ┊              fragmentName: 'FullChat',
-┊121┊   ┊              data: fullChat,
-┊122┊   ┊            });
-┊123┊   ┊
-┊124┊   ┊            let clientChatsData: ChatsQuery | null;
-┊125┊   ┊            try {
-┊126┊   ┊              clientChatsData = client.readQuery({
-┊127┊   ┊                query: queries.chats,
-┊128┊   ┊              });
-┊129┊   ┊            } catch (e) {
-┊130┊   ┊              return;
-┊131┊   ┊            }
-┊132┊   ┊
-┊133┊   ┊            if (!clientChatsData || !clientChatsData.chats) {
-┊134┊   ┊              return null;
-┊135┊   ┊            }
-┊136┊   ┊            const chats = clientChatsData.chats;
-┊137┊   ┊
-┊138┊   ┊            const chatIndex = chats.findIndex(
-┊139┊   ┊              (currentChat: any) => currentChat.id === chatId
-┊140┊   ┊            );
-┊141┊   ┊            if (chatIndex === -1) return;
-┊142┊   ┊            const chatWhereAdded = chats[chatIndex];
-┊143┊   ┊
-┊144┊   ┊            // The chat will appear at the top of the ChatsList component
-┊145┊   ┊            chats.splice(chatIndex, 1);
-┊146┊   ┊            chats.unshift(chatWhereAdded);
-┊147┊   ┊
-┊148┊   ┊            client.writeQuery({
-┊149┊   ┊              query: queries.chats,
-┊150┊   ┊              data: { chats: chats },
-┊151┊   ┊            });
+┊   ┊ 76┊            writeMessage(client, data.addMessage);
 ┊152┊ 77┊          }
 ┊153┊ 78┊        },
 ┊154┊ 79┊      });

One thing missing that you might notice is that we're trying to retrieve the chat from the received message, unfortunately our GraphQL schema doesn't support it and we will need to add it. On the server, we will add a chat field to the Message type in the GraphQL schema, and we will implement a resolver which will lookup for the chat in the chats collection:

Changed schema/resolvers.ts
@@ -6,6 +6,12 @@
 ┊ 6┊ 6┊  Date: DateTimeResolver,
 ┊ 7┊ 7┊  URL: URLResolver,
 ┊ 8┊ 8┊
+┊  ┊ 9┊  Message: {
+┊  ┊10┊    chat(message) {
+┊  ┊11┊      return chats.find(c => c.messages.some(m => m === message.id)) || null;
+┊  ┊12┊    },
+┊  ┊13┊  },
+┊  ┊14┊
 ┊ 9┊15┊  Chat: {
 ┊10┊16┊    messages(chat) {
 ┊11┊17┊      return messages.filter((m) => chat.messages.includes(m.id));
Changed schema/typeDefs.graphql
@@ -5,6 +5,7 @@
 ┊ 5┊ 5┊  id: ID!
 ┊ 6┊ 6┊  content: String!
 ┊ 7┊ 7┊  createdAt: Date!
+┊  ┊ 8┊  chat: Chat
 ┊ 8┊ 9┊}
 ┊ 9┊10┊
 ┊10┊11┊type Chat {

Now that we have it supported we can update the Message fragment in the client to include that information. We don't need the entire chat, only its ID, since the fragment ID composition is done out of an ID and type name:

Changed src/components/ChatRoomScreen/index.tsx
@@ -68,6 +68,10 @@
 ┊68┊68┊            __typename: 'Message',
 ┊69┊69┊            id: Math.random().toString(36).substr(2, 9),
 ┊70┊70┊            createdAt: new Date(),
+┊  ┊71┊            chat: {
+┊  ┊72┊              __typename: 'Chat',
+┊  ┊73┊              id: chatId,
+┊  ┊74┊            },
 ┊71┊75┊            content,
 ┊72┊76┊          },
 ┊73┊77┊        },
Changed src/components/ChatsListScreen/ChatsList.test.tsx
@@ -44,6 +44,10 @@
 ┊44┊44┊                  id: 1,
 ┊45┊45┊                  content: 'Hello',
 ┊46┊46┊                  createdAt: new Date('1 Jan 2019 GMT'),
+┊  ┊47┊                  chat: {
+┊  ┊48┊                    __typename: 'Chat',
+┊  ┊49┊                    id: 1,
+┊  ┊50┊                  },
 ┊47┊51┊                },
 ┊48┊52┊              },
 ┊49┊53┊            ],
@@ -90,6 +94,10 @@
 ┊ 90┊ 94┊                  id: 1,
 ┊ 91┊ 95┊                  content: 'Hello',
 ┊ 92┊ 96┊                  createdAt: new Date('1 Jan 2019 GMT'),
+┊   ┊ 97┊                  chat: {
+┊   ┊ 98┊                    __typename: 'Chat',
+┊   ┊ 99┊                    id: 1,
+┊   ┊100┊                  },
 ┊ 93┊101┊                },
 ┊ 94┊102┊              },
 ┊ 95┊103┊            ],
Changed src/graphql/fragments/message.fragment.ts
@@ -5,5 +5,8 @@
 ┊ 5┊ 5┊    id
 ┊ 6┊ 6┊    createdAt
 ┊ 7┊ 7┊    content
+┊  ┊ 8┊    chat {
+┊  ┊ 9┊      id
+┊  ┊10┊    }
 ┊ 8┊11┊  }
 ┊ 9┊12┊`;

Finally, we will import the useCacheService React hook that we've just created and we will use it in our main App component. This means that the cache service will start listening for changes right as the app component is mounted:

Changed src/App.test.tsx
@@ -3,9 +3,15 @@
 ┊ 3┊ 3┊import ReactDOM from 'react-dom';
 ┊ 4┊ 4┊import App from './App';
 ┊ 5┊ 5┊import { mockApolloClient } from './test-helpers';
+┊  ┊ 6┊import * as subscriptions from './graphql/subscriptions';
 ┊ 6┊ 7┊
 ┊ 7┊ 8┊it('renders without crashing', () => {
-┊ 8┊  ┊  const client = mockApolloClient();
+┊  ┊ 9┊  const client = mockApolloClient([
+┊  ┊10┊    {
+┊  ┊11┊      request: { query: subscriptions.messageAdded },
+┊  ┊12┊      result: { data: {} }
+┊  ┊13┊    }
+┊  ┊14┊  ]);
 ┊ 9┊15┊  const div = document.createElement('div');
 ┊10┊16┊
 ┊11┊17┊  ReactDOM.render(
Changed src/App.tsx
@@ -8,26 +8,31 @@
 ┊ 8┊ 8┊import ChatRoomScreen from './components/ChatRoomScreen';
 ┊ 9┊ 9┊import ChatsListScreen from './components/ChatsListScreen';
 ┊10┊10┊import AnimatedSwitch from './components/AnimatedSwitch';
+┊  ┊11┊import { useCacheService } from './services/cache.service';
 ┊11┊12┊
-┊12┊  ┊const App: React.FC = () => (
-┊13┊  ┊  <BrowserRouter>
-┊14┊  ┊    <AnimatedSwitch>
-┊15┊  ┊      <Route exact path="/chats" component={ChatsListScreen} />
+┊  ┊13┊const App: React.FC = () => {
+┊  ┊14┊  useCacheService();
 ┊16┊15┊
-┊17┊  ┊      <Route
-┊18┊  ┊        exact
-┊19┊  ┊        path="/chats/:chatId"
-┊20┊  ┊        component={({
-┊21┊  ┊          match,
-┊22┊  ┊          history,
-┊23┊  ┊        }: RouteComponentProps<{ chatId: string }>) => (
-┊24┊  ┊          <ChatRoomScreen chatId={match.params.chatId} history={history} />
-┊25┊  ┊        )}
-┊26┊  ┊      />
-┊27┊  ┊    </AnimatedSwitch>
-┊28┊  ┊    <Route exact path="/" render={redirectToChats} />
-┊29┊  ┊  </BrowserRouter>
-┊30┊  ┊);
+┊  ┊16┊  return (
+┊  ┊17┊    <BrowserRouter>
+┊  ┊18┊      <AnimatedSwitch>
+┊  ┊19┊        <Route exact path="/chats" component={ChatsListScreen} />
+┊  ┊20┊
+┊  ┊21┊        <Route
+┊  ┊22┊          exact
+┊  ┊23┊          path="/chats/:chatId"
+┊  ┊24┊          component={({
+┊  ┊25┊            match,
+┊  ┊26┊            history,
+┊  ┊27┊          }: RouteComponentProps<{ chatId: string }>) => (
+┊  ┊28┊            <ChatRoomScreen chatId={match.params.chatId} history={history} />
+┊  ┊29┊          )}
+┊  ┊30┊        />
+┊  ┊31┊      </AnimatedSwitch>
+┊  ┊32┊      <Route exact path="/" render={redirectToChats} />
+┊  ┊33┊    </BrowserRouter>
+┊  ┊34┊  );
+┊  ┊35┊};
 ┊31┊36┊
 ┊32┊37┊const redirectToChats = () => <Redirect to="/chats" />;

Subscription handling is complete! If you'll try to repeat the same process again where you check messages updating between 2 instances of the app, you should see them both update.


TODO: useCacheService shouldn’t be called like that since it’s related to message events and cache updates are only side-effects.

< Previous Step Next Step >