Working with the useQuery
React Hook
Now that we've set up Apollo Client, we can integrate it into our React app. This lets us use React Hooks to bind the results of GraphQL queries directly to our UI.
Integrate with React
To connect Apollo Client to React, we wrap our app in the ApolloProvider
component from the @apollo/client
package. We pass our client instance to the ApolloProvider
component via the client
prop.
Open src/index.tsx
and replace its contents with the following:
import {ApolloClient,NormalizedCacheObject,ApolloProvider,} from "@apollo/client";import { cache } from "./cache";import React from "react";import ReactDOM from "react-dom/client";import Pages from "./pages";import injectStyles from "./styles";// Initialize ApolloClientconst client: ApolloClient<NormalizedCacheObject> = new ApolloClient({cache,uri: "http://localhost:4000/graphql",});injectStyles();// Find our rootElement or throw and error if it doesn't existconst rootElement = document.getElementById("root");if (!rootElement) throw new Error("Failed to find the root element");const root = ReactDOM.createRoot(rootElement);// Pass the ApolloClient instance to the ApolloProvider componentroot.render(<ApolloProvider client={client}><Pages /></ApolloProvider>);
The ApolloProvider
component is similar to React’s context provider: it wraps your React app and places client
on the context, which enables you to access it from anywhere in your component tree.
Now we're ready to build React components that execute GraphQL queries.
Display a list of launches
Let's build the page in our app that shows a list of available SpaceX launches. Open src/pages/launches.tsx
. Right now, the file looks like this:
import React from "react";import { gql } from "@apollo/client";export const LAUNCH_TILE_DATA = gql`fragment LaunchTile on Launch {__typenameidisBookedrocket {idname}mission {namemissionPatch}}`;interface LaunchesProps {}const Launches: React.FC<LaunchesProps> = () => {return <div />;};export default Launches;
Define the query
First, we'll define the shape of the query we'll use to fetch a paginated list of launches. Paste the following below the declaration of LAUNCH_TILE_DATA
:
export const GET_LAUNCHES = gql`query GetLaunchList($after: String) {launches(after: $after) {cursorhasMorelaunches {...LaunchTile}}}${LAUNCH_TILE_DATA}`;
Use LazyQuery
Using fragments
Notice that our query definition pulls in the LAUNCH_TILE_DATA
definition above it. LAUNCH_TILE_DATA
defines a GraphQL fragment, which is named LaunchTile
. A fragment is useful for defining a set of fields that you can include across multiple queries without rewriting them.
In the query above, we include the LaunchTile
fragment in our query by preceding it with ...
, similar to JavaScript spread syntax.
Pagination details
Notice that in addition to fetching a list of launches
, our query fetches hasMore
and cursor
fields. That's because the launches
query returns paginated results:
- The
hasMore
field indicates whether there are additional launches beyond the list returned by the server. - The
cursor
field indicates the client's current position within the list of launches. We can execute the query again and provide our most recentcursor
as the value of the$after
variable to fetch the next set of launches in the list.
Apply the useQuery
hook
We'll use Apollo Client's useQuery
React Hook to execute our new query within the Launches
component. The hook's result object provides properties that help us populate and render our component throughout the query's execution.
- Modify your
@apollo/client
import to includeuseQuery
, and import a few predefined components for rendering the page:
import { gql, useQuery } from "@apollo/client";import { LaunchTile, Header, Button, Loading } from "../components";
Since we are using TypeScript, we'll also import the necessary types that are generated from your server's schema definitions:
import * as GetLaunchListTypes from "./__generated__/GetLaunchList";
Finally, be sure to import Fragment
from React.
import React, { Fragment } from "react";
- Replace the dummy declaration of
const Launches
with the following:
const Launches: React.FC<LaunchesProps> = () => {const { data, loading, error } = useQuery<GetLaunchListTypes.GetLaunchList,GetLaunchListTypes.GetLaunchListVariables>(GET_LAUNCHES);if (loading) return <Loading />;if (error) return <p>ERROR</p>;if (!data) return <p>Not found</p>;return (<Fragment><Header />{data.launches &&data.launches.launches &&data.launches.launches.map((launch: any) => (<LaunchTile key={launch.id} launch={launch} />))}</Fragment>);};
This component passes our GET_LAUNCHES
query to useQuery
and obtains data
, loading
, and error
properties from the result. Depending on the state of those properties, we render a list of launches, a loading indicator, or an error message.
Start up both your server and client with npm start
and visit localhost:3000
. If everything's configured correctly, our app's main page appears and lists 20 SpaceX launches!
We have a problem though: there are more than 20 SpaceX launches in total. Our server paginates its results and includes a maximum of 20 launches in a single response.
To be able to fetch and store all launches, we need to modify our code to use the cursor
and hasMore
fields included in our query. Let's learn how.
Add pagination support
Apollo Client 3 provides new pagination helper functions for offset-based and Relay-style pagination that are not yet reflected in this tutorial.
Apollo Client provides a fetchMore
helper function to assist with paginated queries. It enables you to execute the same query with different values for variables (such as the current cursor).
Add fetchMore
to the list of objects we destructure from the useQuery
result object, and also define an isLoadingMore
state variable:
At this point, you'll need to import useState
from React. At the top of launches.tsx
file, add useState
:
import React, { Fragment, useState } from "react";
const Launches: React.FC<LaunchesProps> = () => {const { data, loading, error, fetchMore } = useQuery<GetLaunchListTypes.GetLaunchList,GetLaunchListTypes.GetLaunchListVariables>(GET_LAUNCHES);const [isLoadingMore, setIsLoadingMore] = useState(false);};
Now we can connect fetchMore
to a button within the Launches
component that fetches additional launches when it's clicked.
Paste this code directly above the closing </Fragment>
tag in the Launches
component:
{data.launches &&data.launches.hasMore &&(isLoadingMore ? (<Loading />) : (<ButtononClick={async () => {setIsLoadingMore(true);await fetchMore({variables: {after: data.launches.cursor,},});setIsLoadingMore(false);}}>Load More</Button>));}//</Fragment>
When our new button is clicked, it calls fetchMore
(passing the current cursor
as the value of the after
variable) and displays a Loading notice until the query returns results.
Let's test our button. Start everything up and visit localhost:3000
again. A Load More button now appears below our 20 launches. Click it. After the query returns, no additional launches appear. 🤔
If you check your browser's network activity, you'll see that the button did in fact send a follow-up query to the server, and the server did in fact respond with a list of launches. However, Apollo Client keeps these lists separate, because they represent the results of queries with different variable values (in this case, the value of after
).
We need Apollo Client to instead merge the launches from our fetchMore
query with the launches from our original query. Let's configure that behavior.
Merge cached results
Apollo Client stores your query results in its in-memory cache. The cache handles most operations intelligently and efficiently, but it doesn't automatically know that we want to merge our two distinct lists of launches. To fix this, we'll define a merge
function for the paginated field in our schema.
Open src/cache.ts
, where our default InMemoryCache
is initialized:
import { InMemoryCache, Reference } from "@apollo/client";export const cache: InMemoryCache = new InMemoryCache({});
The schema field that our server paginates is the list of launches
. Modify the initialization of cache
to add a merge
function for the launches
field, like so:
export const cache: InMemoryCache = new InMemoryCache({typePolicies: {Query: {fields: {launches: {keyArgs: false,merge(existing, incoming) {let launches: Reference[] = [];if (existing && existing.launches) {launches = launches.concat(existing.launches);}if (incoming && incoming.launches) {launches = launches.concat(incoming.launches);}return {...incoming,launches,};},},},},},});
This merge
function takes our existing
cached launches and the incoming
launches and combines them into a single list, which it then returns. The cache stores this combined list and returns it to all queries that use the launches
field.
This example demonstrates a use of field policies, which are cache configuration options that are specific to individual fields in your schema.
If you try clicking the Load More button now, the UI will successfully append additional launches to the list!
Display a single launch's details
We want to be able to click a launch in our list to view its full details. Open src/pages/launch.tsx
and replace its contents with the following:
import { gql } from "@apollo/client";import { LAUNCH_TILE_DATA } from "./launches";export const GET_LAUNCH_DETAILS = gql`query LaunchDetails($launchId: ID!) {launch(id: $launchId) {siterocket {type}...LaunchTile}}${LAUNCH_TILE_DATA}`;
This query includes all the details we need for the page. Notice that we're reusing the LAUNCH_TILE_DATA
fragment that's already defined in launches.tsx
.
Once again, we'll pass our query to the useQuery
hook. This time, we also need to pass the corresponding launch's launchId
to the query as a variable. We'll use React Router's useParams
hook to access the launchId
from our current URL.
Now replace the contents of launch.tsx
with the following:
import React, { Fragment } from "react";import { gql, useQuery } from "@apollo/client";import { LAUNCH_TILE_DATA } from "./launches";import { Loading, Header, LaunchDetail } from "../components";import { ActionButton } from "../containers";import { useParams } from "react-router-dom";import * as LaunchDetailsTypes from "./__generated__/LaunchDetails";export const GET_LAUNCH_DETAILS = gql`query LaunchDetails($launchId: ID!) {launch(id: $launchId) {siterocket {type}...LaunchTile}}${LAUNCH_TILE_DATA}`;interface LaunchProps {}const Launch: React.FC<LaunchProps> = () => {let { launchId } = useParams();// This ensures we pass a string, even if useParams returns `undefined`launchId ??= "";const { data, loading, error } = useQuery<LaunchDetailsTypes.LaunchDetails,LaunchDetailsTypes.LaunchDetailsVariables>(GET_LAUNCH_DETAILS, { variables: { launchId } });if (loading) return <Loading />;if (error) return <p>ERROR: {error.message}</p>;if (!data) return <p>Not found</p>;return (<Fragment><Headerimage={data.launch && data.launch.mission && data.launch.mission.missionPatch}>{data && data.launch && data.launch.mission && data.launch.mission.name}</Header><LaunchDetail {...data.launch} /><ActionButton {...data.launch} /></Fragment>);};export default Launch;
Just like before, we use the status of the query to render either a loading
or error
state, or data when the query completes.
Return to your app and click a launch in the list to view its details page.
Display the profile page
We want a user's profile page to display a list of launches that they've booked a seat on. Open src/pages/profile.tsx
and replace its contents with the following:
import React, { Fragment } from "react";import { gql, useQuery } from "@apollo/client";import { Loading, Header, LaunchTile } from "../components";import { LAUNCH_TILE_DATA } from "./launches";import * as GetMyTripsTypes from "./__generated__/GetMyTrips";export const GET_MY_TRIPS = gql`query GetMyTrips {me {idtrips {...LaunchTile}}}${LAUNCH_TILE_DATA}`;interface ProfileProps {}const Profile: React.FC<ProfileProps> = () => {const { data, loading, error } = useQuery<GetMyTripsTypes.GetMyTrips>(GET_MY_TRIPS,{ fetchPolicy: "network-only" });if (loading) return <Loading />;if (error) return <p>ERROR: {error.message}</p>;if (data === undefined) return <p>ERROR</p>;return (<Fragment><Header>My Trips</Header>{data.me && data.me.trips.length ? (data.me.trips.map((launch: any) => (<LaunchTile key={launch.id} launch={launch} />))) : (<p>You haven't booked any trips</p>)}</Fragment>);};export default Profile;
You should recognize all of the concepts in this code from the pages we've already completed, with one highlighted exception: we're setting a fetchPolicy
.
Customizing the fetch policy
As mentioned earlier, Apollo Client stores query results in its cache. If you query for data that's already present in your cache, Apollo Client can return that data without needing to fetch it over the network.
However, cached data can become stale. Slightly stale data is acceptable in many cases, but we definitely want our user's list of booked trips to be up to date. To handle this, we've specified a fetch policy for our GET_MY_TRIPS
query.
A fetch policy defines how Apollo Client uses the cache for a particular query. The default policy is cache-first
, which means Apollo Client checks the cache to see if the result is present before making a network request. If the result is present, no network request occurs.
By setting this query's fetch policy to network-only
, we guarantee that Apollo Client always queries our server to fetch the user's most up-to-date list of booked trips.
For a list of all supported fetch policies, see Supported fetch policies.
If you visit the profile page in your app, you'll notice that the query returns null. This is because we still need to implement login functionality. We'll tackle that in the next section!