GraphQL Example: Real-time Chat App Using Subscriptions

The code for this example can be found on GitHub here: https://github.com/jmgraff/strawberry-graphql-chat-app.

This project builds off our previous full-stack GraphQL example. If you’re new to GraphQL and haven’t read it yet, you should check it out.

What we’re making

We’re going to make a real-time chat application using GraphQL subscriptions. GraphQL subscriptions are implemented with WebSockets and allow the server to push information to the client when it’s ready rather than the client having to ask for it.

The code for this example will be built on top of the FastAPI + Strawberry / React + URQL template here: https://github.com/jmgraff/fastapi-strawberry-urql and explained in this article.

The Backend

For our backend, we’ll be using:

  • uvicorn web server
  • FastAPI framework
  • Strawberry GraphQL package

The Schema

from uuid import uuid4
import typing
import asyncio

import strawberry

@strawberry.type
class User:
    uuid: strawberry.Private[str]
    name: str

@strawberry.type
class Message:
    sender: User
    text: str

@strawberry.type
class Room:
    updated: strawberry.Private[typing.Optional[asyncio.Event]] = None
    name: str = "Example Chat"
    users: typing.List[User]
    messages: typing.List[Message]

    def get_user_by_uuid(self, uuid):
        return next(user for user in self.users if user.uuid == uuid)

    def add_user(self, name):
        uuid = str(uuid4())
        room.users.append(User(name=name, uuid=uuid))
        return uuid

    def add_message(self, uuid, text):
        self.messages.append(Message(
            sender=self.get_user_by_uuid(uuid),
            text=text
        ))
        self.updated.set()
        self.updated.clear()
        return self

    async def wait_for_update(self):
        if self.updated is None:
            self.updated = asyncio.Event()
        await self.updated.wait()

room = Room(users=[], messages=[])

@strawberry.type
class Query:
    @strawberry.field
    async def get_room() -> Room:
        return room

@strawberry.type
class Subscription:
    @strawberry.subscription
    async def room() -> typing.AsyncGenerator[Room, None]:
        while True:
            yield await Query.get_room()
            await room.wait_for_update()

@strawberry.type
class Mutation:
    @strawberry.field
    async def join(name: str) -> str:
        return room.add_user(name)

    @strawberry.field
    async def send_message(uuid: str, text: str) -> Room:
        return room.add_message(uuid, text)
backend/gql.py

Our GraphQL schema will consist of 3 custom types:

  • User – this will hold a very simple representation of a User. When they connect, they’ll be given a UUID and a name
  • Message – this will represent a message sent by a user. It’ll have a User object associated with it, as well as a string to represent the message itself.
  • Room – this will represent the chat room. It’ll contain all the connected users and messages.

The Room Type

Let’s start with the most complicated type, the Room:

@strawberry.type
class Message:
    sender: User
    text: str

@strawberry.type
class Room:
    updated: strawberry.Private[typing.Optional[asyncio.Event]] = None
    name: str = "Example Chat"
    users: typing.List[User]
    messages: typing.List[Message]

    def get_user_by_uuid(self, uuid):
        return next(user for user in self.users if user.uuid == uuid)

    def add_user(self, name):
        uuid = str(uuid4())
        room.users.append(User(name=name, uuid=uuid))
        return uuid

    def add_message(self, uuid, text):
        self.messages.append(Message(
            sender=self.get_user_by_uuid(uuid),
            text=text
        ))
        self.updated.set()
        self.updated.clear()
        return self

    async def wait_for_update(self):
        if self.updated is None:
            self.updated = asyncio.Event()
        await self.updated.wait()

room = Room(users=[], messages=[])
backend/gql.py

In this type, we have a private field called updated which contains an asyncio.Event object. This is the primitive that we’ll be using to trigger real-time updates when messages are sent. It’s not instantiated by default, because we have to instantiate asyncio.Events in the same event loop as the rest of the application, so we do that when wait_for_update is called if it doesn’t already exist.

It’s important to note that the event has to be created here because it’s within the asyncio loop. If we were to create it when the Room object is first instantiated as a default value, it would be crated before the loop starts, and not be associated with it.

In add_message, we have one half of what makes this app real-time. Once a message has been added, we fire the updated event, which is awaited on by the wait_for_update method. That method then is called from our Subscription, which we’ll get to in a little bit.

The name, users, and messages fields are pretty self-explanatory.

I kept this very simple to make the example easier to follow. In a real-world application, we’d be doing a lot more error checking here. The add_user method, for instance, should really check to make sure a user with that name doesn’t already exist, and return an error to the frontend if it does. Likewise, get_user_by_uuid should also return an error to the frontend if it can’t find a user.

The User and Message Types

Here’s the two types that’ll represent our users and their messages:

import strawberry

@strawberry.type
class User:
    uuid: strawberry.Private[str]
    name: str

@strawberry.type
class Message:
    sender: User
    text: str

@strawberry.type
class Room:
backend/gql.py

Very simple, but one thing to note is the uuid field in User is private. We do this because we don’t want any user to be able to query the uuid of another user, otherwise they’d be able to masquerade as them if they were to go poking around at our API.

The Room Instance

       await self.updated.wait()

room = Room(users=[], messages=[])

@strawberry.type
backend/gql.py

To keep the example simple, we just hold all our app data in memory by instantiating a room object. Normally, we’d be storing this stuff in a database.

The Query

room = Room(users=[], messages=[])

@strawberry.type
class Query:
    @strawberry.field
    async def get_room() -> Room:
        return room

@strawberry.type
class Subscription:
backend/gql.py

We only have one query defined, and that’s get_room. All it does is return the global room instance we created in the previous section. Again, in the real world, we’d probably want to get this info from a database.

Even though we don’t really need this query since we’re just using subscriptions (explained below), it’s still required to define a query type to pass to strawberry.Schema and it has to have at least one field in it.

The Subscription

Here’s what actually makes this app real-time:

       return room

@strawberry.type
class Subscription:
    @strawberry.subscription
    async def room() -> typing.AsyncGenerator[Room, None]:
        while True:
            yield await Query.get_room()
            await room.wait_for_update()

@strawberry.type
backend/gql.py

To create a subscription field, we have to annotate it with @straberry.subscription. All subscription fields must return an AsyncGenerator. In the method itself, we start up an infinite loop with the while True, then immediately yield the current state of the room object. Then, we call room.wait_for_update, which if you recall, waits for the updated event to fire.

Unlike the polling method of constantly getting fresh data from the server, this subscription will only send new data when there’s new data to send. This is thanks to the asyncio.Event primitive that we wait on in the room.wait_for_update method.

The Mutations

            await room.wait_for_update()

@strawberry.type
class Mutation:
    @strawberry.field
    async def join(name: str) -> str:
        return room.add_user(name)

    @strawberry.field
    async def send_message(uuid: str, text: str) -> Room:
        return room.add_message(uuid, text)
backend/gql.py

Our mutations are very simple. When a user “joins” the server with the join mutation, their chosen name is added to to the room’s list of users, and their generated UUID for the current session is returned to the frontend to be used for sending messages. When a user sends a message with send_message, they send the message itself in the text parameter, and let the server know who they are with the uuid parameter.

This is a very simple (and fairly insecure) way to implement some rudimentary authentication. UUIDs are hard enough to guess and trivial enough to generate that they’re good enough for this example. They function as the “client secret” so our server is reasonably sure the sender is who they say they are. Please don’t do something like this in a real application!

The Frontend

Our frontend will be built with

  • ReactJS app generated with create-react-app
  • URQL GraphQL client

The URQL GraphQL client

import { createClient, defaultExchanges, subscriptionExchange } from "urql";
import { SubscriptionClient } from "subscriptions-transport-ws";

const subscriptionClient = new SubscriptionClient(
            `ws://${process.env.REACT_APP_HOSTNAME}:8000/graphql`, {
            reconnect: true,
            lazy: true
        });

export const urqlClient = createClient({
    url: `http://${process.env.REACT_APP_HOSTNAME}:8000/graphql`,
    exchanges: [
        subscriptionExchange({
            forwardSubscription: (operation) => subscriptionClient.request(operation)
        }),
        ...defaultExchanges
    ]
});
frontend/src/utils.js

Our URQL client requires the NPM package subscriptions-transport-ws to use GraphQL subscriptions. You can install it with the following command executed from the frontend directory:

npm install subscriptions-transport-ws
ShellScript

The SubscriptionClient

import { createClient, defaultExchanges, subscriptionExchange } from "urql";
import { SubscriptionClient } from "subscriptions-transport-ws";

const subscriptionClient = new SubscriptionClient(
            `ws://${process.env.REACT_APP_HOSTNAME}:8000/graphql`, {
            reconnect: true,
            lazy: true
        });

export const urqlClient = createClient({
frontend/src/utils.js

We instantiate a new SubscriptionClient object by passing in our backend server’s GraphQL endpoint, as well as an object of options.

Don’t get confused by the process.env.REACT_APP_HOSTNAME; that is a special environment variable that create-react-app can access at build time, which was exported in our Makefile. For more info, check out our full-stack GraphQL example that we based this project on.

The Subscription Exchange

      });

export const urqlClient = createClient({
    url: `http://${process.env.REACT_APP_HOSTNAME}:8000/graphql`,
    exchanges: [
        subscriptionExchange({
            forwardSubscription: (operation) => subscriptionClient.request(operation)
        }),
        ...defaultExchanges
    ]
});
frontend/src/utils.js

To add our subscription client to our app, we have to put it in a subscriptionExchange and add it to the exchanges list in our URQL config object. If we forget to do this, we’ll get an error stating that nothing has handled the “subscription” operation.

The Default Exchanges

        }),
        ...defaultExchanges
    ]
});
frontend/src/utils.js

Then, below the subscription exchange, we simply “splat” the rest of the default exchanges.

The Room Component

Our Room component will maintain a real-time list of messages in the chat room:

import { useSubscription } from "urql";

import "./Room.css";

const ROOM = `
    subscription {
        room {
            name
            messages {
                sender {
                    name
                }
                text
            }
        }
    }
`;

function Message({sender, text}) {
    return (
        <div className="message">
            <div className="sender">{sender}:</div>
            <div className="text">{text}</div>
        </div>
    );
}

export default function Room() {
    const [{data, fetching}] = useSubscription({query: ROOM}, (_, data) => data);

    if (fetching) {
        return (
            <h1>Loading...</h1>
        );

    } else {
        let messages = [];
        data?.room.messages.forEach((message, index) => {
            messages.push(
                <Message
                    key={index}
                    sender={message.sender.name}
                    text={message.text}
                    className="message"
                />
            );
        });

        return (
            <div>
                <h1>{data?.room.name}</h1>
                <div id="messages">
                    <div>{messages}</div>
                </div>
            </div>
        );
    }
}
frontend/src/Room.js

Note that we assign the index of the forEach loop as a key prop to the <Message> components above. This is done to prevent a warning about list elements needing unique key props to identify them.

The Subscription GraphQL Query

import "./Room.css";

const ROOM = `
    subscription {
        room {
            name
            messages {
                sender {
                    name
                }
                text
            }
        }
    }
`;

function Message({sender, text}) {
frontend/src/Room.js

Taking a closer look at the ROOM subscription GraphQL query, we can see it’s almost the same as a regular query. The difference is, instead of being executed once when the component mounts, it’ll maintain a WebSocket connection to the server and receive push updates as messages come in. For the fields we want, we’re grabbing the name of the room, each message sender’s name, and the message itself.

The useSubscription Call

}

export default function Room() {
    const [{data, fetching}] = useSubscription({query: ROOM}, (_, data) => data);

    if (fetching) {
frontend/src/Room.js

When we use the subscription, we have to also pass in a reducer function, which is the arrow function you see after where we specify the ROOM query. If we were being really efficient, we’d take apart the data returned from the server and only use the new messages. But, to keep things simple, we just return the whole object every time.

The Message Form

The message form is how we’ll send messages to the server. It’s also our log-in form where we’ll set our username and receive a UUID if we don’t have one already.

import { useState } from "react";
import { useMutation } from "urql";

const SEND_MESSAGE = `
    mutation ($uuid: String!, $text: String!) {
        sendMessage(uuid: $uuid, text: $text) {
            name
        }
    }
`;

const JOIN = `
    mutation ($name: String!) {
        join(name: $name)
    }
`;

export default function Message() {
    const [{fetching: sendMessageFetching}, sendMessage] = useMutation(SEND_MESSAGE);
    const [{fetching: useMutationFetching, data}, join] = useMutation(JOIN);
    const fetching = sendMessageFetching || useMutationFetching;
    const [message, setMessage] = useState("");
    const [name, setName] = useState("");

    const handleSendMessage = (ee) => {
        ee.preventDefault();
        sendMessage({uuid: data.join, text: message});
        setMessage("");
    };

    const handleJoin = (ee) => {
        ee.preventDefault();
        join({name});
        setName("");
    };

    if (!data?.join) {
        return (
            <form onSubmit={handleJoin}>
                <input
                    type="text"
                    value={name}
                    onChange={(ee) => setName(ee.target.value)}
                    disabled={fetching}
                />
                <button type="submit">Join</button>
            </form>
        );

    } else {
        return (
            <form onSubmit={handleSendMessage}>
                <input
                    type="text"
                    value={message}
                    onChange={(ee) => setMessage(ee.target.value)}
                    disabled={fetching}
                />
                <button type="submit">Submit</button>
            </form>
        );
    }
}
frontend/src/MessageForm.js

The sendMessage Mutation

import { useMutation } from "urql";

const SEND_MESSAGE = `
    mutation ($uuid: String!, $text: String!) {
        sendMessage(uuid: $uuid, text: $text) {
            name
        }
    }
`;

const JOIN = `
frontend/src/MessageForm.js

Like we saw in the backend mutations, this takes 2 arguments:

  • the uuid that was generated when we joined (explained in the next section)
  • the text of the message we want to send

Since this mutation returns the Room again, we have to select something from it, so we just grab the name.

The join Mutation

;

const JOIN = `
    mutation ($name: String!) {
        join(name: $name)
    }
`;

export default function Message() {
frontend/src/MessageForm.js

This is the mutation we execute when we want to join the room. All it takes is a name, and it’ll return our uuid. Since the UUID is a string, which is a scalar, we don’t have to select any sub-fields. We can just access it from data.join.

Joining the Room

export default function Message() {
    const [{fetching: sendMessageFetching}, sendMessage] = useMutation(SEND_MESSAGE);
    const [{fetching: useMutationFetching, data}, join] = useMutation(JOIN);
    const fetching = sendMessageFetching || useMutationFetching;
    const [message, setMessage] = useState("");
    const [name, setName] = useState("");

    const handleSendMessage = (ee) => {
        ee.preventDefault();
        sendMessage({uuid: data.join, text: message});
        setMessage("");
    };

    const handleJoin = (ee) => {
        ee.preventDefault();
        join({name});
        setName("");
    };

    if (!data?.join) {
        return (
            <form onSubmit={handleJoin}>
                <input
                    type="text"
                    value={name}
                    onChange={(ee) => setName(ee.target.value)}
                    disabled={fetching}
                />
                <button type="submit">Join</button>
            </form>
        );

    } else {
        return (
            <form onSubmit={handleSendMessage}>
                <input
                    type="text"
                    value={message}
                    onChange={(ee) => setMessage(ee.target.value)}
                    disabled={fetching}
                />
                <button type="submit">Submit</button>
            </form>
        );
    }
}
frontend/src/MessageForm.js

Above you can see highlighted the logic we’re going to be using to join the room. If we don’t already have a UUID (which we get from data.join), we will render the form to set our username. When we submit this form, we’ll execute the join mutation and get our UUID.

Sending a Message

export default function Message() {
    const [{fetching: sendMessageFetching}, sendMessage] = useMutation(SEND_MESSAGE);
    const [{fetching: useMutationFetching, data}, join] = useMutation(JOIN);
    const fetching = sendMessageFetching || useMutationFetching;
    const [message, setMessage] = useState("");
    const [name, setName] = useState("");

    const handleSendMessage = (ee) => {
        ee.preventDefault();
        sendMessage({uuid: data.join, text: message});
        setMessage("");
    };

    const handleJoin = (ee) => {
        ee.preventDefault();
        join({name});
        setName("");
    };

    if (!data?.join) {
        return (
            <form onSubmit={handleJoin}>
                <input
                    type="text"
                    value={name}
                    onChange={(ee) => setName(ee.target.value)}
                    disabled={fetching}
                />
                <button type="submit">Join</button>
            </form>
        );

    } else {
        return (
            <form onSubmit={handleSendMessage}>
                <input
                    type="text"
                    value={message}
                    onChange={(ee) => setMessage(ee.target.value)}
                    disabled={fetching}
                />
                <button type="submit">Submit</button>
            </form>
        );
    }
}
frontend/src/MessageForm.js

Once we’ve officially joined the room, we render the form to send a message. When its submitted, we pass in our uuid we got from joining, along with our message.

The Finished Product

And here’s what the final product looks like:

Bring back memories of AOL instant messenger?

Conclusion

The key to real-time GraphQL applications is requests. Rather than having the frontend poll for new information at regular intervals, requests allow the server to push information to the client only when it’s available. Implementation of GraphQL requests is very straightforward with the Strawberry GraphQL framework.