Rick and Morty pair programming · Generated using DALL·E 2

Improving the user experience of page navigation with Apollo Client

Using partial list view data to pre-fill detail views

Joosep Alviste
Scoro Product Engineering
10 min readJan 25, 2023

--

When implementing single-page applications, we often have loading states all over the place while fetching data from an API. When used sparingly to load secondary data, it can be a nice way to speed up loading the main information on the page.

However, when navigating between pages, the loading states often tend to block whole views, hiding all information from the user until the API requests have finished. This can lead to a sub-par user experience where the user needs to wait for the loading to finish before they can even start to process the new view.

Interestingly, one of the advantages of single-page applications is that there is no full page refresh when navigating between pages, which, in theory, should lessen this problem.

In this article, we’ll leverage the client-side cache of Apollo Client to avoid big loading states and provide a better experience for the user when navigating between pages.

One of the most common flows in applications is having a list view where clicking on an item takes the user to the detailed view of that particular item. Then, in the details view, some additional data is fetched for the item and rendered on the page.

The simplest approach would be to show a loading state blocking the whole view while the details are being loaded. This is, again, not great when it comes to user experience. Ideally, we should be able to show the data that we have already fetched for the item while loading the missing data. This way, the user can already get familiar with the UI while the rest of the data is loading.

Achieving such a flow can be a bit tricky, but Apollo Client can make it quite simple and intuitive. We’ll go through an example application, implementing this behavior from scratch.

Here’s how it will look like in the end (see the deployed demo here):

Gif showing the desired user experience

TL;DR: Here are the example commits from the demo application:

The demo application

The application we’ll use to demo the solution uses the free Rick and Morty GraphQL API to show a list of characters and their details. The demo app is a simple SPA built with Vue, Apollo Client, GraphQL Code Generator, and TypeScript. I won’t cover the technologies in detail in this blog post, feel free to browse their documentation pages for more information. I would mention though that this approach is framework-agnostic, meaning that this would also work with React instead of Vue, for example.

The source code of the demo application is available here. It’s also hosted on Netlify and you can even edit the example yourself without cloning it on StackBlitz.

In the list view, we show the names and pictures of each character. In the details view, we show their gender, status, and species info as well as a list of episodes they appear in.

We’ll start our work from this commit, where the list and detail views have been implemented with a standard loading state that blocks the whole user interface:

Gif showing the loading state covering the whole page

Note how when navigating to the details view of a character, neither their name nor picture is shown even though we have already fetched that information from the API.

The most important files for us are:

  • src/main.ts where we configure our Apollo Client
  • src/components/ListView.vue where we fetch and show the list of characters
  • src/components/DetailView.vue where we fetch and show the detailed information of a single character

The Apollo Cache

In order to be able to solve the problem, we first need to understand how our Apollo Cache behaves and how it’s structured. Using the Apollo Client Devtools, we can see how the cache looks like in the list view:

Apollo Cache structure in the list view

Which corresponds to the list query we have:

query characters {
characters {
results {
id
image
name
}
}
}

We can see that the cache only includes the fields we fetched for the list view (because we’re using GraphQL). The cache itself is normalized, meaning that each entity is separately kept track of in the cache (I would highly recommend reading the blog post from the Apollo blog about Demystifying Cache Normalization if you’re interested in how it works).

For the details view, our query looks like this:

query character($characterId: ID!) {
character(id: $characterId) {
id
name
image
gender
status
species
episode {
id
name
}
}
}

When the details page is visited, then the character query is executed. Apollo Client will try to access the (missing) ROOT_QUERY.character field in the cache, fail, and trigger the GraphQL request. Once the details are fetched, we’ll have a result for the character({“id”: “1”}) query, and the cache for a single character will include a bit more information:

Apollo Cache structure in the details view

It looks like the main thing we need to do is to say to Apollo Client that if a character({"id":"1"}) query is made, then it should check if we already have some data for that character available in the cache (with the key Character:1). If so, return it to the component so it can be rendered while the rest of the data is still loading.

Using partial data for the details

This is done in the demo application in commit a3b3aac.

There are two parts to making Apollo Client behave like we want:

  1. Configuring the Apollo Cache to return data from the cache for the character details query
  2. Allowing Apollo Client to return a result for the query even if not all the required fields exist in the cache

Returning custom data from the cache

First up, we’ll need to update the Apollo Client cache configuration in src/main.ts. The typePolicies field allows us to configure how exactly Apollo reads data from and writes to the cache. We can use it to add our own logic to how the character query is resolved. Here is how the default implementation would look like:

const apolloClient = new ApolloClient({
uri: 'https://rickandmortyapi.com/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
character: {
read(existing) {
// Just return any existing data if there is anything
return existing;
},
},
},
},
},
}),
});

What we need it to do is to return an existing object from the cache if the existing parameter is undefined. The second argument is an object that includes the following fields: args, giving us the query arguments; toReference, allowing us to generate refs of objects (basically cache keys; for example, "Character:1"); and cache, allowing us to interact with the Apollo Cache instance. These helpers give us everything we need to write our custom cache reading logic:

read(existing, { args, toReference, cache }) {
if (!args || existing) {
// If there is an existing result in the cache, use that
return existing;
}

// We need to generate a key that we will use for querying the cache
// E.g., "Character:1"
const ref = toReference({
__typename: 'Character',
id: args.id,
});

// Check if we have an existing character already saved into Apollo
// Cache.
const existingCharacter = cache.readFragment({
id: ref?.__ref,
fragment: gql`
fragment MyCharacter on Character {
id
}
`,
});

return existingCharacter ? ref : undefined;
},

This is still not quite enough to make our solution work, though. When navigating to the details view, the GraphQL query will be triggered. Apollo Client will read the ROOT_QUERY.character field from the cache, which will trigger our read function, returning the existing character info.

Next, Apollo Client will check if all the requested fields exist in the returned data. We only have partial data in the form of the id, name, and image fields, but no gender, status or other requested fields. So, the API request will be triggered and the result won’t be populated in the component.

Allow using partial data

Thankfully, it is very easy to allow a query to return a result even if not all the data exists in the cache. We can just use the returnPartialData option of useQuery:

const { result, loading } = useQuery(
graphql(`
...our query
`),
{ characterId: props.id },
{
// This allows Apollo Client to return us data in `result` even if not all
// the required fields exist.
returnPartialData: true,
}
);

Now, result will always be populated if we have any data in the cache, and loading is populated when the request is in progress. So, when navigating to the details view from the list view, result will instantly contain the partial response data for the query (excluding the gender, status, species, and episode fields). loading will be true though, so we’ll need to update our template a little bit to start showing the partial data:

<div v-if="!character && loading">Loading...</div>
<div v-else-if="character" class="details">
<h2>
{{ character.name }} (<router-link to="/">Back to list</router-link>)
</h2>
<img v-if="character.image" :src="character.image" class="image" />

<!-- Only show these elements if we have the full response available -->
<template v-if="character.episode">
<div>Gender: {{ character.gender }}</div>
<div>Status: {{ character.status }}</div>
<div>Species: {{ character.species }}</div>
<div>
<div>Episodes:</div>
<ul class="episodes-list">
<li v-for="episode in character.episode">
{{ episode?.name }}
</li>
</ul>
</div>
</template>
<div v-else-if="loading">Loading...</div>
</div>

Navigating between the pages uses partial data now and the application is much nicer to use!

Navigating between pages with partial data

Adding type safety

This is done in the demo application in commit 01c25e6.

There is one thing that’s still less than ideal about our solution: it is not type-safe. If we wouldn’t add the check with v-if="character.episode", then we would not get any TypeScript errors warning us that character.gender and other fields might be missing. We want TypeScript to warn us in case we’re using some fields that might be missing without any checks.

Currently, useQuery is typed automatically with the help of GraphQL Code Generator to always return the list data. However, useQuery accepts some type parameters that we can use to modify its return type to match our special use case.

The first thing we can do is to add types for the character data, both from the list and details query:

import {
CharacterQuery,
CharactersQuery,
} from '../gql/graphql';

type CharacterWithPartialData = NonNullable<
NonNullable<NonNullable<CharactersQuery['characters']>['results']>[number]
>;

type CharacterWithFullData = NonNullable<CharacterQuery['character']>;

It is a little verbose, but we could add a generic helper type to make it easier to understand.

Next, let’s add one type that describes the response with either the partial data or the full data using a simple union type:

type PartialCharacterQueryResponse = {
__typename?: 'Query';
character?: CharacterWithPartialData | CharacterWithFullData | null;
};

Now we can make useQuery return the correct type of data:

import { CharacterQueryVariables } from '../gql/graphql';

const { result, loading } = useQuery<
PartialCharacterQueryResponse,
CharacterQueryVariables
>(
graphql(`
...
`),
{ characterId: props.id },
{ returnPartialData: true }
);

After this, we’ll get the correct type information in result! If we don’t add a check when rendering the extra data, then we’ll get a TypeScript error warning us that we might not have the fields available:

TypeScript error warning that the extra fields don’t exist

Finally, with these types in place, we can add a function that helps us to check whether we’re dealing with partial data or full data so that TypeScript would also be aware of the types. Type predicates can help us implement this:

const areDetailsLoaded = (
character: NonNullable<PartialCharacterQueryResponse['character']>
): character is CharacterWithFullData => {
return Boolean('episode' in character);
};

The character is CharacterWithFullData annotation tells TypeScript that if this function returns true, then the character parameter is indeed CharacterWithFullData. Here’s a small example:

const myCharacter: CharacterWithPartialData | CharacterWithFullData | null = {
// ...
}
// myCharacter is typed as
// CharacterWithPartialData | CharacterWithFullData | null

if (!myCharacter) {
return;
}
// myCharacter is CharacterWithPartialData | CharacterWithFullData

if (!areDetailsLoaded(myCharacter)) {
// myCharacter is CharacterWithPartialData
return;
}
// myCharacter is CharacterWithFullData

Let’s use that helper function in the template:

<template v-if="areDetailsLoaded(character)">
<div>Gender: {{ character.gender }}</div>
<div>Status: {{ character.status }}</div>
<div>Species: {{ character.species }}</div>
<div>
<div>Episodes:</div>
<ul class="episodes-list">
<li v-for="episode in character.episode">
{{ episode?.name }}
</li>
</ul>
</div>
</template>
<div v-else-if="loading">Loading...</div>

Now we have a fully type-safe way of handling partial data from Apollo Client!

Final words

We’ve just achieved a much more user-friendly way of navigating between views by rendering the data that we already have in the cache. The page navigation is smoother for the user and they don’t need to wait, staring at a loading indicator while the page is loading. We didn’t need to change the component logic too much and we could leverage Apollo Client for using the cached data. As a cherry on top, the solution is type-safe and we can be rather confident that there won’t be runtime errors, even though the logic is not super straightforward.

As a next step, we could extract some helper functions that could be used for the typePolicies read function. As mentioned above, some generic types could also be added to make typing the query responses easier. When using this approach in more than a couple of places, it definitely makes sense to extract these helpers.

Let us know in the comments if you have any other nice use cases for the custom cache reading with typePolicies. Finally, go and try it out in your own web applications!

If solving problems like this one seems interesting, check out our open positions at https://www.scoro.com/careers/!

The cover illustration of the article was generated with the help of DALL-E 2, openai.com. The prompt used was: “cute rick and morty pair programming and eating doritos, digital art”.

--

--