react native

Cache Expiration in Apollo GraphQL using React Hooks

by

The Problem

Our mobile apps include recommended support content to help users get the most out of their smartphones and other connected devices. We needed a way to refresh periodically to keep this support content up to date and effective.

Recommended support content

The Solution

Our mobile apps are built with React Native and server communication is done via GraphQL with Apollo. To solve the problem of refreshing recommended support content, we created a React hook, useCacheWithExpiration, to decide when to fetch data from the cache vs. the server. The Apollo client chooses the data source based on a FetchPolicy. At a high level, the policies used by our hook are:

  • cache-only: fetch data from the cache
  • network-only: fetch data from the server
  • cache-first: fetch data from the cache if it satisfies the query, otherwise, fetch from the server

Now, let’s take a look at useCacheWithExpiration in action:

const query = // ...

const fetchPolicy = useCacheWithExpiration(
  moment.Duration(24, 'hours'),
  'lastFetch_recommendedSupportContent'
)

useEffect(() => {
  apolloClient.query({
    query,
    fetchPolicy
  })
  .then(({ data }) => {
    // ...
  })
}, [apolloClient, fetchPolicy])

We use our new hook to determine the FetchPolicy for recommended support content. Then, anytime fetchPolicy changes, we re-run our query using a useEffect hook. To see why fetchPolicy might change, let’s dive deeper into how its value is determined:

const useCacheWithExpiration = (
  expiration: moment.Duration,
  key: string
): FetchPolicy => {
  const [fetchPolicy, setFetchPolicy] = useState<FetchPolicy>('cache-only')

  useEffect(() => {
    AsyncStorage.getItem(key)
      .then(lastFetch => {
        if (
          lastFetch === null ||
          moment().diff(moment(lastFetch)) > expiration.asMilliseconds()
        ) {
          AsyncStorage.setItem(key, moment().toISOString()).catch(() =>
            console.warn(`Failed to save time of last fetch for ${key}`)
          )
          setFetchPolicy('network-only')
        } else {
          setFetchPolicy('cache-first')
        }
      })
      .catch(() => setFetchPolicy('network-only'))
  }, [])

  return fetchPolicy
}

Initially, we fetch data from the cache to cut down on load time from the user’s perspective. Then, we look up the timestamp of the last fetch from the server for individual queries using a unique key. If we’ve never fetched from the server before or the data is now expired (based on expiration), we update the fetch policy to point to the server, and re-run the query.

Takeaways

useCacheWithExpiration highlights a few nice things about React hooks in general. They allow you to encapsulate logic that can be shared across your app and expose the results of that logic in a nice, reactive way. Though initially created to refresh recommended support content, we’ve now used useCacheWithExpiration for many queries across our app to keep data fresh.