Now that our server's ready let's start making our frontend! We won't be adding any CSS in this article, but you can definitely style it later on!
Initializing your frontend
At the root of your project run the following. We'll be using TypeScript here as well.
npx create-react-app chat-client --template typescript
Once that's done, add the dependencies we'll need. We'll be using Apollo Client for this tutorial, so run,
yarn add @apollo/client graphql subscriptions-transport-ws
As Apollo Client subscriptions communicate over the WebSocket protocol, we use the subscription-transport-ws library.
Apollo Client setup
Now let's add in our initial setup! Open up App.tsx
and add the following,
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { ApolloProvider } from "@apollo/client";
import { useState } from "react";
const client = new ApolloClient({
uri: 'http://localhost:9000/graphql',
cache: new InMemoryCache(),
});
const App = () => {
const [name, setName] = useState<string>("");
const [entered, setEntered] = useState<boolean>(false);
return (
<ApolloProvider client={client}>
<div className="App">
{!entered && (
<div>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
></input>
<button onClick={() => setEntered(true)}>Enter chat</button>
</div>
)}
{name !== "" && entered && (
<div>
Chats
</div>
)}
</div>
</ApolloProvider>
);
};
export default App;
Alright, let's breakdown what we wrote!
First, we initialized an ApolloClient
instance, client
, with our GraphQL server endpoint and the InMemoryCache()
class provided by apollo. We then connect our client
to React, by passing it as a prop to ApolloProvider
. This will wrap our React app and place our client in context which means that we can access our client
from anywhere in our component tree and execute GraphQL operations.
Now, we would want a name from our user, so that the user can send chats in our chat app. So we declare a name
state to store our user's name and an entered
state so that we can figure when to show the chats and when to show an "enter chat" screen which would let the user enter their name. We use pretty simple conditional rendering to do this.
If the user hasn't entered the chat or provided their name, i.e, if entered
is false, we show an input field to set the name
state and an "Enter chat" button which sets entered
to true. If entered
is true and name
isn't an empty string, we show chats (we'll be adding components for this soon). Also, we'll be using name
as a local state and threading it through our components for now.
This is great up till now, but if you remember, our GraphQL API has a query, mutation, and a subscription. The query and mutation are resolved via our HTTP endpoint, but the subscription requires a separate WebSocket endpoint, which we haven't provided to our client yet. So let's go ahead and add that!
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { ApolloProvider } from "@apollo/client";
import { WebSocketLink } from "@apollo/client/link/ws";
import { split, HttpLink } from "@apollo/client";
import { getMainDefinition } from "@apollo/client/utilities";
import { useState } from "react";
const wsLink = new WebSocketLink({
uri: "ws://localhost:9000/subscriptions",
options: {
reconnect: true,
},
});
const httpLink = new HttpLink({
uri: "http://localhost:9000/graphql",
credentials: "include",
});
const link = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === "OperationDefinition" &&
definition.operation === "subscription"
);
},
wsLink,
httpLink
);
const client = new ApolloClient({
link,
cache: new InMemoryCache(),
});
const App = () => {
const [name, setName] = useState<string>("");
const [entered, setEntered] = useState<boolean>(false);
return (
<ApolloProvider client={client}>
<div className="App">
{!entered && (
<div>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
></input>
<button onClick={() => setEntered(true)}>Enter chat</button>
</div>
)}
{name !== "" && entered && (
<div>
Chats
</div>
)}
</div>
</ApolloProvider>
);
};
export default App;
Alright, so our client
changed up quite a bit!
First, we initialize a WebSocketLink
instance with our GraphQL API's subsciption endpoint. We also initialize a HttpLink
instance with our GraphQL API's HTTP endpoint.
Now, since queries and mutations don't require a long-lasting real-time connection, http would be much more efficient for them. Thus, we could like to split our communication on the basis of the GraphQL operation required, i.e, we want to use HttpLink
if it's a query or a mutation, but would switch over to WebSocketLink
if it's a subscription.
We achieve this by using the split()
function which assigns link
based on a boolean check. It takes in three parameters, a function that's called for each operation to execute, a link if the function returns a "truthy" value, and a link if the function returns a "falsy" value. Here, we use the getMainDefinition()
function to check if the operation in a subscription. If that returns true we use wsLink
otherwise we use httpLink
. link
is later passed into our client
.
Executing a mutation
Now that that's out of the way, let's figure out how to send a message in our chat app. We'll be using our createChat
mutation in this case. Create a new file, SendMessage.tsx
in the src
directory and type the following,
import { useState, FC } from "react";
import { gql, useMutation } from "@apollo/client";
const SEND_MESSAGE = gql`
mutation createChat($name: String!, $message: String!) {
createChat(name: $name, message: $message) {
id
name
message
}
}
`;
interface SendMessageProps {
name: string;
}
const SendMessage: FC<SendMessageProps> = ({ name }) => {
const [input, setInput] = useState<string>("");
const [sendMessage, { data }] = useMutation(SEND_MESSAGE);
const handleSend = () => {
sendMessage({ variables: { name: name, message: input } })
.then((data) => {
console.log(data);
setInput("");
})
.catch((err) => console.log(err));
};
return (
<div>
<input
type="text"
id="message"
value={input}
onChange={(e) => setInput(e.target.value)}
></input>
<button onClick={handleSend}>Send message</button>
</div>
);
};
export default SendMessage;
Alright, we have a really simple component this time, with one input field to fill out the message the user wants to send, which is stored in our input
state and a button that calls the handleSend()
function when it's clicked. It also takes in the name of the user as a prop. The most important thing to note here is our mutation.
We use the useMutation
hook from Apollo to call our mutation. We've defined our mutation query as a GraphQL string, SEND_MESSAGE
which we pass into our hook. The useMutation
hook in turn returns a tuple that has a mutate function (sendMessage()
here) which we can call to execute the mutation and an object with fields that represent the current status of the mutation. We won't be using that object here for now.
We call the sendMessage()
mutate function inside our handleSend
method. Since our mutation has input variables, namely, name
and message
, we pass those in as the variables
object, with values from our props and state. The mutate function returns a Promise
so we use then()
here to wait for the mutation to execute. Once the mutation is done we clear out the input
state so that the user can type and send the next message. You can test this out now and view the messages you send in the console!
Executing a query
Now, we also need to be able to show our previous chats and update that whenever a new chat is sent. So let's define a new Chats.tsx
component with the following code to accomplish this,
import { gql, useQuery } from "@apollo/client";
const ALL_CHATS = gql`
query allChats {
getChats {
id
name
message
}
}
`;
const Chats = () => {
const { loading, error, data } = useQuery(ALL_CHATS);
if (loading) return <p>"Loading...";</p>;
if (error) return <p>`Error! ${error.message}`</p>;
return (
<div>
{data.getChats.map((chat: any) => (
<div key={chat.id}>
<p>
{chat.name}: {chat.message}
</p>
</div>
))}
</div>
);
};
export default Chats;
Alright, let's understand what we wrote. We used the useQuery
hook by Apollo, to execute our allChats
query, which is defined as a GraphQL string, ALL_CHATS
. When our component renders, the useQuery
hook returns an object with loading
, error
, and data
which we then use to render our UI.
When there's no error, and the data is done loading, we loop through our chats and display the name of the sender and the message. Keep in mind that Apollo Client automatically caches our query results locally, to make subsequent query results faster.
Use subscription to update query result
There's no real-time aspect in the Chat
component yet. So sending in new chats won't update our UI unless we refresh. Let's fix this by adding in our subscription.
import { gql, useQuery } from "@apollo/client";
import { useEffect } from "react";
const ALL_CHATS = gql`
query allChats {
getChats {
id
name
message
}
}
`;
const CHATS_SUBSCRIPTION = gql`
subscription OnNewChat {
messageSent {
id
name
message
}
}
`;
const Chats = () => {
const { loading, error, data, subscribeToMore } = useQuery(ALL_CHATS);
useEffect(() => {
subscribeToMore({
document: CHATS_SUBSCRIPTION,
updateQuery: (prev, { subscriptionData }) => {
if (!subscriptionData.data) return prev;
const newChat = subscriptionData.data.messageSent;
return {
getChats: [...prev.getChats, newChat],
};
},
});
}, []);
if (loading) return <p>"Loading...";</p>;
if (error) return <p>`Error! ${error.message}`</p>;
return (
<div>
{data.getChats.map((chat: any) => (
<div key={chat.id}>
<p>
{chat.name}: {chat.message}
</p>
</div>
))}
</div>
);
};
export default Chats;
We just changed a bunch of stuff so let's figure out what we did.
If you look closely, our UI logic hasn't changed one bit. However, our data fetching logic has.
The useQuery
hook returns another function, subscribeToMore()
. We can use this function to execute a followup GraphQL subscription that can push updates to our query's, i.e allChats
, original results.
Now, we use the subscribeToMore()
function inside a useEffect
hook which has an empty dependency array, i.e, it fires when the component is mounted. We pass in two options to the subscribeToMore()
function, document
which indicates which subscription needs to be executed, and updateQuery
which is a function that tells Apollo Client how to combine the query's currently cached result (prev
here) with the subscriptionData
that's pushed by our GraphQL subscription. The return value of this function completely replaces the current cached result for the query.
Thus, for document
we pass in our subscription CHATS_SUBSCRIPTION
defined as a GraphQL string, and for updateQuery
, we pass in a function that appends the newChat
received from our subscription to our previous chat data and returns that as an object that our UI can iterate over. The object is of the same type as the results of our allChats
query but now has the latest chat at the last index of the getChats
field array. Since this is a subscription, our cached chats will now get updated the moment a new chat arrives!
You might be wondering why we don't just execute the subscription using a useSubscription
hook, eliminating the query altogether. We could, but this would result in the user getting only the messages after the user has entered the chat. We want to show previous chats as well which is why we chose this approach.
Test it out
Finally, let's use the Chats
and SendMessage
component in our App.tsx
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { ApolloProvider } from "@apollo/client";
import { WebSocketLink } from "@apollo/client/link/ws";
import { split, HttpLink } from "@apollo/client";
import { getMainDefinition } from "@apollo/client/utilities";
import Chats from "./Chats";
import SendMessage from "./SendMessage";
import { useState } from "react";
const wsLink = new WebSocketLink({
uri: "ws://localhost:9000/subscriptions",
options: {
reconnect: true,
},
});
const httpLink = new HttpLink({
uri: "http://localhost:9000/graphql",
credentials: "include",
});
const link = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === "OperationDefinition" &&
definition.operation === "subscription"
);
},
wsLink,
httpLink
);
const client = new ApolloClient({
link,
cache: new InMemoryCache(),
});
const App = () => {
const [name, setName] = useState<string>("");
const [entered, setEntered] = useState<boolean>(false);
return (
<ApolloProvider client={client}>
<div className="App">
{!entered && (
<div>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
></input>
<button onClick={() => setEntered(true)}>Enter chat</button>
</div>
)}
{name !== "" && entered && (
<div>
<Chats />
<SendMessage name={name} />
</div>
)}
</div>
</ApolloProvider>
);
};
export default App;
After saving, run yarn start
and visit localhost:3000, enter the chat from 2 or 3 different browser tabs, and see the chats you send appear instantaneously in all tabs.
And voilà! We've successfully managed to make a full-stack chat application using GraphQL and TypeScript! You can now build on this even further and add in styles, a database, and even an authentication mechanism!
Conclusion
If you'd like to dig deeper into GraphQL, Apollo Client/Server, and TypeGraphQL and discover all the cool things you can make with it, read the official docs,
Also, here's an awesome list of resources to learn further!
If you get stuck here's the repo with all the code!
Top comments (2)
Hi I have one question related to the performance aspect of Query+SubcribeToMore. As per Network tab in Chrome dev tools each time a new message is added, the entire messages array is being fetched from the server. Is there any way to optimize this?
If you see the screenshot, the data transferred over the network is increasing with every new message.
dev-to-uploads.s3.amazonaws.com/up...
👍