Using Apollo Client
To fetch data in our application, we are going to use Apollo Client. Start by installing the packages:
npm install @apollo/client graphql
Setting up Apollo Client in React requires an Apollo Client instance. Create a directory src/services/apollo-client
and initialize a client in apollo-client.ts
:
import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client";
export const client = new ApolloClient({
link: createHttpLink({
uri: "http://localhost:3333",
}),
cache: new InMemoryCache(),
});
To make a query with Apollo Client, we need to first define our query constant
. We do this with gql
, this is a template literal tag that parses the query document string, converting it into a structured component:
import { gql } from "apollo/client"
const GET_TIME_ENTRIES = gql`
query GetTimeEntries {
allTimeEntries {
id
activity
client
endTime
startTime
}
}
`;
To store this query constant
, create a new directory in the src
folder named graphql
. In this directory make a new directory called time-entries
and in this directory, make a file called queries.ts
, where you export the query constant
.
To connect Apollo Client to React, we need to use the ApolloProvider
component. Similar to React's Context.Provider
, this component wraps around your app and places Apollo Client on the context, enabling you to access it from anywhere in your component tree. We're going to add the ApolloProvider
to our Providers
client component:
'use client';
import { ApolloProvider } from '@apollo/client';
import { ThemeProvider } from 'styled-components';
import { StoreProvider } from './StoreContext';
import { client } from '../services/apollo-client';
import { theme } from '../styles/theme';
export default function Providers({ children }) {
return (
<ApolloProvider client={client}>
<StoreProvider>
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
</StoreProvider>
</ApolloProvider>
);
}
Now we can start using queries!
Queries
useQuery()
Apollo Client uses a hook called useQuery
for executing the query. The syntax is as follows:
const { data, error, loading, refetch } = useQuery(GET_TIME_ENTRIES);
As you can see, the query constant
is passed as an argument to this hook. Moreover, there are four main things returned by useQuery
:
- data: An object containing the result of your GraphQL query after it completes. This may be
undefined
if there are any errors. - error: If the query produces one or more errors, this object contains either an array of
graphQLErrors
or a singlenetworkError
. Otherwise, this value isundefined
. - loading: If
true
, the query is still in progress and results have not yet been returned. - refetch: This can be used to, as the name suggests, refetch the query. The result of the refetch will be returned to the
data
object. Your refetch can also contain (new) variables.
There are more results that can be returned by the hook, which you can find on the Apollo Client Docs.
When having multiple queries, you can assign data
, error
and loading
to new names to avoid conflicts in the following way:
const { data: timeEntryData } = useQuery(GET_TIME_ENTRIES);
Furthermore, to simplify syntax, we can deconstruct the data
object as follows:
const { timeEntries = {} } = data;
Recall that data
can be undefined: to still deconstruct it, we can initialize the deconstruction as an empty object.
Polling
As of right now, we only perform a query when we refetch the page. But what if someone adds or removes a time entry after you've loaded the page? To keep the time entries up to date, we can simply add polling. This is done by setting the pollInterval
option to our query:
const { data: timeEntryData } = useQuery(GET_TIME_ENTRIES, {
pollInterval: 10000,
});
Where the pollInterval takes a value in milliseconds.
Currently, we are polling for data every 10 seconds. This has two downsides:
- In the case that in this 10 seconds, the time entries have not been changed, an unnecessary request is made. For one user this is not a big issue, but if there are thousands of users, this can take a big toll on our server.
- In case of for example a messenger app, 10 seconds is a long time and the user will have a big delay between incoming messages.
Mutations
We only touched upon queries so far, which is analogous to a POST
in a REST-ful API. Luckily, it is also possible to edit and delete entries using GraphQL. Unlike with REST-ful API's, there is one encapsulating term for both PUT
and DELETE
operations, a Mutation
. The usage is very similar to queries. For example, we can add a time entry with the following mutation constant
, which we put in /src/graphql/time-entries/mutations.ts
:
export const ADD_TIME_ENTRY = gql`
mutation CreateTimeEntry($client: String!, $endTimestamp: String!, $startTimestamp: String!) {
createTimeEntry(client: $client, endTimestamp: $endTimestamp, startTimestamp: $startTimestamp) {
id client date: { endTimestamp startTimestamp }
}
}
`;
useMutation
Now, we can use the useMutation
as follows:
const [addNewTimeEntry, { data, error, loading }] = useMutation(ADD_TIME_ENTRY);
You can see that the useMutation
hook returns addNewTimeEntry
(you can give this any name you want), which is known as the mutate function
. This function can be used to execute the mutation. Unlike useQuery
, useMutation
does not execute automatically on a render.
The other returns are similar to the ones defined for useQuery
.
After implementing this you will see that our app has a bug: after you add or remove a time entry, the data is not always updated. Meaning, you will only see the result if you refresh the page. To fix this, we must first understand what is happening:
Caching
In the JavaScript Apollo Client, caching is enabled by default. There are different types of caching:
- HTTP caching: Whenever an HTTP request with a query operation is sent to the server, the response is saved for a certain configurable period of time in a file. When subsequent identical requests are made, Apollo will check the file, find the saved response, and return it instead of sending the request to the server.
- Normalized caching: Take objects out of query responses and save them by type and ID. If an object that is part of another query is changed, any code watching that query is given the updated results.
When querying, we can specify a fetch policy with Response Fetchers:
CACHE_ONLY
: Try to resolve the query from the cache.NETWORK_ONLY
: Try to resolve from the network (by sending the query to the server).CACHE_FIRST
(default): First try the cache, and if the result isn’t there, use the network.NETWORK_FIRST
: First try the network, and if we don’t get a response from the server, look in the cache.CACHE_AND_NETWORK
(only available with a normalized cache): First try reading from the cache. Then, even if we found the data in the cache, make the network request.
Normalized cache
All of the libraries we’ll use cache the data we get from the GraphQL server. They store the data, either in memory or on disk, so that we can immediately access it later without having to request it again from the server. While some libraries just cache the whole response and give us the cached response when we make the exact same request, the most useful libraries store the data in a normalized cache. A normalized cache breaks down the response and saves each object separately, so that if we make a different, overlapping query, the library can still give us the cached data.
Back to the problem
So in short, when you execute a mutation, you modify back-end data. Usually, you then want to update your locally cached data to reflect the back-end modification. It is good to know that Apollo Client provides the possibility to manually update cache.
However, in our case it is sufficient to re-fetch the time entries after we add or remove one, so we always render an up-to-date version of the entries. For this, we make use of the refetchQueries option
in our useMutations
:
const [addNewTimeEntry] = useMutation(ADD_TIME_ENTRY, {
refetchQueries: [{ query: GET_TIME_ENTRIES }],
});
const [deleteTimeEntry] = useMutation(REMOVE_TIME_ENTRY, {
refetchQueries: [{ query: GET_TIME_ENTRIES }],
});
Now, Apollo wil re-fetch the time entries after the mutation. Perfect!
Bonus
To manually update the cache, so we don't make any unnecessary networks that decrease performance, we can use the update
function. An update
function attempts to replicate a mutation's back-end modifications in your client's local cache. These cache modifications are broadcast to all affected active queries, which updates your UI automatically. If the update
function does this correctly, your users see the latest data immediately, without needing to await another network round trip.
For the delete mutation, this would be implemented as follows:
await deleteTimeEntry({
variables: {
id,
},
update: (cache) => {
cache.writeQuery({
query: GET_TIME_ENTRIES,
data: {
allTimeEntries: allTimeEntries.filter(
(timeEntry: TimeEntryProps) => timeEntry.id !== id
),
},
});
},
});
SSR
To increase the performance of our application, we would like to use SSR for our GraphQL requests too. Luckily, the implementation of this is relatively simple.
First of, we have to tell Apollo Client that we want to use the client for SSR, which prevents Apollo Client from refetching queries unnecessarily:
export const client = new ApolloClient({
cache: new InMemoryCache(),
link: createHttpLink({
uri: "http://localhost:3333",
}),
ssrMode: typeof window === "undefined",
});
Next, we will define our getServerSideProps as follows:
export const getServerSideProps = async () => {
const { data } = await client.query({
query: GET_TIME_ENTRIES;
});
return {
props: {
initialTimeEntries: data.allTimeEntries,
},
};
};
}
As you can see, we don’t make use of the useQuery
hook, as hooks can only be used within React components. We use client.query
instead.
Also note that we won't use the returned data directly. Instead, we make use of the fact that with this call, the GraphQL cache has been updated with all the time entries and clients. When calling useQuery
, Apollo Client uses the cache instead of making another query to the server.
(To prove that this works, disable JS on localhost:3000 and use the option ssr: false
to the useQuery
. With JS off, time entries are only visible when ssr
is set to true
)