8. Fetching data with queries
10m

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 queries directly to our UI.

Integrate with React

To connect 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:

client/src/index.tsx
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 ApolloClient
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
cache,
uri: "http://localhost:4000/graphql",
});
injectStyles();
// Find our rootElement or throw and error if it doesn't exist
const 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 component
root.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 queries.

Display a list of launches

Let's build the page in our app that shows a list of available SpaceX . Open src/pages/launches.tsx. Right now, the file looks like this:

client/src/pages/launches.tsx
import React from "react";
import { gql } from "@apollo/client";
export const LAUNCH_TILE_DATA = gql`
fragment LaunchTile on Launch {
__typename
id
isBooked
rocket {
id
name
}
mission {
name
missionPatch
}
}
`;
interface LaunchesProps {}
const Launches: React.FC<LaunchesProps> = () => {
return <div />;
};
export default Launches;

Define the query

First, we'll define the shape of the we'll use to fetch a paginated list of . Paste the following below the declaration of LAUNCH_TILE_DATA:

client/src/pages/launches.tsx
export const GET_LAUNCHES = gql`
query GetLaunchList($after: String) {
launches(after: $after) {
cursor
hasMore
launches {
...LaunchTile
}
}
}
${LAUNCH_TILE_DATA}
`;

Use LazyQuery

Using fragments

Notice that our definition pulls in the LAUNCH_TILE_DATA definition above it. LAUNCH_TILE_DATA defines a fragment, which is named LaunchTile. A is useful for defining a set of that you can include across multiple queries without rewriting them.

In the above, we include the LaunchTile in our by preceding it with ..., similar to JavaScript spread syntax.

Pagination details

Notice that in addition to fetching a list of launches, our fetches hasMore and cursor . That's because the launches returns paginated results:

  • The hasMore indicates whether there are additional beyond the list returned by the server.
  • The cursor indicates the client's current position within the list of . We can execute the again and provide our most recent cursor as the value of the $after to fetch the next set of in the list.

Apply the useQuery hook

We'll use 's useQuery React Hook to execute our new within the Launches component. The hook's result object provides properties that help us populate and render our component throughout the 's execution.

  1. Modify your @apollo/client import to include useQuery, and import a few predefined components for rendering the page:
client/src/pages/launches.tsx
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:

client/src/pages/launches.tsx
import * as GetLaunchListTypes from "./__generated__/GetLaunchList";

Finally, be sure to import Fragment from React.

client/src/pages/launches.tsx
import React, { Fragment } from "react";
  1. Replace the dummy declaration of const Launches with the following:
client/src/pages/launches.tsx
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 to useQuery and obtains data, loading, and error properties from the result. Depending on the state of those properties, we render a list of , 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 !

Task!

We have a problem though: there are more than 20 SpaceX 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 , we need to modify our code to use the cursor and hasMore included in our . Let's learn how.

Add pagination support

3 provides new pagination helper functions for offset-based and Relay-style pagination that are not yet reflected in this tutorial.

provides a fetchMore helper function to assist with paginated queries. It enables you to execute the same with different values for (such as the current ).

Add fetchMore to the list of objects we destructure from the useQuery result object, and also define an isLoadingMore state :

At this point, you'll need to import useState from React. At the top of launches.tsx file, add useState:

client/src/pages/launches.tsx
import React, { Fragment, useState } from "react";
client/src/pages/launches.tsx
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 when it's clicked.

Paste this code directly above the closing </Fragment> tag in the Launches component:

client/src/pages/launches.tsx
{
data.launches &&
data.launches.hasMore &&
(isLoadingMore ? (
<Loading />
) : (
<Button
onClick={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 ) and displays a Loading notice until the returns results.

Let's test our button. Start everything up and visit localhost:3000 again. A Load More button now appears below our 20 . Click it. After the 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 to the server, and the server did in fact respond with a list of . However, keeps these lists separate, because they represent the results of queries with different variable values (in this case, the value of after).

We need to instead merge the from our fetchMore with the from our original . Let's configure that behavior.

Merge cached results

stores your results in its in-memory cache. The cache handles most intelligently and efficiently, but it doesn't automatically know that we want to merge our two distinct lists of . To fix this, we'll define a merge function for the paginated in our schema.

Open src/cache.ts, where our default InMemoryCache is initialized:

client/src/cache.ts
import { InMemoryCache, Reference } from "@apollo/client";
export const cache: InMemoryCache = new InMemoryCache({});

The schema that our server paginates is the list of launches. Modify the initialization of cache to add a merge function for the launches , like so:

client/src/cache.ts
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 and the incoming 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 .

This example demonstrates a use of field policies, which are cache configuration options that are specific to individual in your schema.

If you try clicking the Load More button now, the UI will successfully append additional to the list!

Task!

Display a single launch's details

We want to be able to click a in our list to view its full details. Open src/pages/launch.tsx and replace its contents with the following:

client/src/pages/launch.tsx
import { gql } from "@apollo/client";
import { LAUNCH_TILE_DATA } from "./launches";
export const GET_LAUNCH_DETAILS = gql`
query LaunchDetails($launchId: ID!) {
launch(id: $launchId) {
site
rocket {
type
}
...LaunchTile
}
}
${LAUNCH_TILE_DATA}
`;

This includes all the details we need for the page. Notice that we're reusing the LAUNCH_TILE_DATA that's already defined in launches.tsx.

Once again, we'll pass our to the useQuery hook. This time, we also need to pass the corresponding 's launchId to the as a . 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:

client/src/pages/launch.tsx
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) {
site
rocket {
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>
<Header
image={
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 to render either a loading or error state, or data when the completes.

Return to your app and click a in the list to view its details page.

Task!

Display the profile page

We want a user's profile page to display a list of that they've booked a seat on. Open src/pages/profile.tsx and replace its contents with the following:

client/src/pages/profile.tsx
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 {
id
email
trips {
...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, stores 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 .

A fetch policy defines how uses the cache for a particular . The default policy is cache-first, which means 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 's fetch policy to network-only, we guarantee that 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 returns null. This is because we still need to implement login functionality. We'll tackle that in the next section!

Previous