Managing Cache for Offset Paginated Data in Apollo

  • Languages
    • Javascript
  • Libraries
    • React

Caching is always a tricky problem to solve. In one of my recent projects, I experimented updating cache for an offset paginated data. In this post, I am going to talk about some of the decisions with regards to handling paginated data that improved the user experience.

What is Offset Pagination?

There are two prominent types of pagination — cursor and offset paginations. Offset-based pagination is a pagination that is mainly defined by numbers. So, if you show 15 items per page and go to page 5, you will get items in range of 60-75. This type of pagination allows the user to jump to an arbitrary page.

Problem with Offset Pagination

The most common scenario for cursor based pagination is stiching items from next pages into existing data (e.g infinite scrolling). On the other hand, offset based pagination only shows the data available in a specific page. As a result, mutations can make existing cache stale (of course, this is also an issue in cursor based pagination but I think it is a much bigger problem with offset based pagination). Let’s look into a simple example to see ONE of the problems with caching this type of data:

Let’s say that we are in page 5 of an arbitrary users collection. According to sorting, newly added user will be located at page 2. As a result, all the items from page 3 and above will be shifted one item forward. Additionally, just because we are in page 5 does not mean that page 2-4 are cached (i.e if a user directly goes to link /users?page=5) — this is one of the behaviors that is not a problem in cursor-based pagination solutions. Caching this type of data can become quiet tricky for us.

There are couple more scenarios like the above that we will discuss as we go further in this post.

Fixing Paginated Data Fetching: cache-and-network policy

I think this is one of the most important parts for managing offset-paginated data in Apollo. When using this fetch policy, the data will be read from the cache. However, regardless of the data in the cache, a new fetch request will be performed to get new data from the server. Using this policy will help us solve a lot of problems because data will always be in sync with backend. You can use this policy by adding it as an option in Apollo query:

const { data, loading, error } = useQuery(ALL_USERS, {
  fetchPolicy: 'cache-and-network'
});

Asking server what page the item is added to

This was something that that was quiet important to properly implement caching. In order to track what page the item was added during mutation, a page field was returned from create and update mutations. This can be a controversial topic for caching but I like to think about this field as something that is available in backend — i.e the backend doesn’t care what this value is used for.

I won’t be showing backend code in this post.

Properly update the cache on mutations

Before properly implementing the cache, we need to know what kind of operations can be performed and list them for easier understanding of what is going on. For this post, I am going assume that we have basic CRUD operations — create, update, and delete.

Since we know which pages the items are added to, we can make certain assumptions and implement the caching mechanism.

Create

Item Added Action Reason
Current Page + 1 Do Nothing Items that are added to the next page do not affect the order of items in the current page. Using cache-and-network always refetches the next page; so, the pages will always be in sync.
Current Page - 1 Refetch Implementing caching here is a painful experience. This is because there are two things that need to be taken into account. Firstly, pages between the location of the new item and the current location might not be queried at all (e.g if we jump from page 1 straight to page 5). Secondly, items in all the pages will need to be shifted by one item. Implementing this will make caching a very complicated and fragile. So, the decision was to just refetch current page.
Current Page Update Cache Because we are in current page, we can access the data in current page and update and sort the cached data to keep the data in sync. In this phase, we will remove the last item from the page. Next page will be reloaded due to cache-and-network; so, we don’t need to track the last item.

Implementation:

// pageInfo: information about paginator
// such as current page, items per page, total pages
const [createUser] = useMutation(CREATE_USER_MUTATION, {
	update(cache, { data }) {
		if (data.page === pageInfo.page) {
			// read from cache
			const { allUsers } = cache.readQuery({
				query: ALL_USERS,
				variables: { page: pageInfo.page }
			});

			if (!allUsers) return;

			// Concatenate existing array with new data
			// and sort the list by name field
			const newData = [...allUsers.data, data.createUser].sort((a, b) => a.name.localeCompare(b.name));

			// Write to cache
			cache.writeQuery({
				query: ALL_USERS,
				variables: { page: pageInfo.page },
				data: {
					allUsers: {
						...allUsers,
						// Remove last item from array
						data: newData.slice(0, pageInfo.itemsPerPage);
					}
				}
			});
		}
	},
	refetchQueries({ data }) {
		// refetchQueries returns an array of queries.
		// We are returning the query for the current page to refetch
		// All subsequent pages will be handled by `cache-and-network` fetch policy
		if (data.page < pageInfo.page) {
		    // Refetch if a new item is added to previous page
			return [
				{
					query: ALL_USERS,
					variables: { page: pageInfo.page }
				}
			];
		}

		return [];
	}
});

Update

Update Location Action Reason
Not in current page Refetch When an item in the current page is updated and moved to another page (e.g name changed while sorting by name), the number of items in the current page will change. So, it is simpler to refetch the data to keep the data in-sync.
Current page Update cache This one is easy because the updated item is already in the cache. When updating the item, sorting might break due to changes in the sorted field. To fix this, we need to sort the data in the cache after mutations.

Implementation:

const [updateUser] = useMutation(UPDATE_USER_MUTATION, {
	update(cache, { data }) {
		if (data.page === pageInfo.page) {
			// Update single item in cache
			cache.writeData({
				id: `USER:${data.updateUser.id}`,
				data: data.updateUser
			});

			// read from cache
			const { allUsers } = cache.readQuery({
				query: ALL_USERS
				variables: { page: pageInfo.page }
			});

			if (!allUsers) return;

			// sort function mutates the original array
			// so, let's create a new array and sort that one
			const newData = [...allUsers.data].sort((a, b) => a.name.localeCompare(b.name));

			// Write to cache
			cache.writeQuery({
				query: ALL_USERS,
				variables: { page: pageInfo.page },
				data: {
					allUsers: {
						...allUsers,
						data: newData
					}
				}
			});
		}
	},
	refetchQueries({ data }) {
		// if updated data is in another page, refetch current page
		if (data.page !== pageInfo.page) {
			return [
				{
					query: ALL_USERS,
					variables: { page: pageInfo.page }
				}
			];
		}

		return [];
	}
);

Delete

I did not find calculating the page where item was deleted from useful; so, deleting always refetches. This is just a decision that I made. If you want to handle cache during delete, you can use the same logic as in create and update.

Conclusion

This was an interesting experiment with regards to managing cache. Currently, it is deployed in one of the projects that I am working on. So far, the reception has been good. I am sure certain gotchas will be revealed and smoothed out as time goes.