SSR with Relay on Next.js: How to determine if data is from the cache or network

Daniel Woelfel
Daniel WoelfelOct 3rd, 2020

This article is really a GitHub issue that was fetched by Relay through OneGraph and turned into an article by Next.js

It uses getStaticProps to deliver a statically-generated version of the article. That way search engines and people with javascript disabled can see the content.

There may be new comments or reactions since the last time Next.js ran getStaticProps, so React hydrates the page on the client and we re-render with the latest version of the data fetched from OneGraph.

Our getStaticProps function runs the query for the article and serializes the Relay environment to JSON, which is what gets used to statically render the page.

import {fetchQuery} from 'react-relay/hooks';

export async function getStaticProps(context) {
  const issueNumber = context.params.number;
  const environment = createRelayEnvironment();
  await fetchQuery(environment, articleQuery, {issueNumber}).toPromise();
  return {
    revalidate: 600,
    props: {
      issueNumber,
      initialRecords: environment
        .getStore()
        .getSource()
        .toJSON(),
    },
  };
}

Then we put the initialRecords into the environment when we hydrate the page on the client.

export const Article = ({issueNumber, initialRecords}) => {
  const environment = useRelayEnvironment();
  // Only add cached records on initial render
  React.useMemo(() => {
    if (initialRecords) {
      environment.getStore().publish(new RecordSource(initialRecords));
    }
  }, [initialRecords]);
  const data = useLazyLoadQuery(
    articleQuery,
    {issueNumber},
    // store-and-network will populate `data` with records from the store and
    // fetch new data from the network in the background
    {fetchPolicy: 'store-and-network'},
  );
  return <Article issue={data.gitHub.repository.issue} />
};

The 'store-and-network' fetchPolicy tells Relay to render with data from the store initially, then send off a network request to get the latest data.

Relay won’t tell you if the data that you’re rendering with is from the network or from the cache. It’s a difficult problem for Relay to solve generally. In many cases, it’s not even clear what it means for data to be cached. Relay has a central store that all queries add data to. It would be difficult and expensive to keep track of the provenance of every piece of data in the store.

For our use-case, the question is more straightforward. Was the data added to the store by publishing our cached initialRecords, or through the fetch that useLazyLoadQuery initiated?

We can answer that question by leveraging client-only fields and custom handlers.

Client-only fields

Relay allows you to extend your server schema with client-only fields. This is typically used for local state management, and can replace tools like redux depending on the use case.

We’ll add a client-only field to the GitHubIssue type by extending the type in a new file called src/clientSchema.graphl.

extend type GitHubIssue {
  isClientFetched: Boolean
}

Then we’ll add the field to our query and specify that it should use a custom handler with the @__clientField directive

query ArticleQuery {
  gitHub {
    repository(name: $name, owner: $owner) {
      issue(number: $issueNumber) {
        isClientFetched @__clientField(handle: "isClientFetched")
        ...ArticleFragment
      }
    }
  }
}

The @__clientField directive takes a handle argument, which is just a string we will use to determine which handler to use.

Custom handlers

Handlers are extension points that allow for custom logic to add data to the store. Relay has default handlers built-in to deal with things like Connection types, a pattern many GraphQL services use for pagination.

We’ll write our own handler for isClientFetched and use it when we construct the environment, with a fallback to the default handler:

import {DefaultHandlerProvider} from 'relay-runtime';

const isClientFetchedHandler = {
  update(store /*: RecordSourceProxy */, payload /*: HandleFieldPayload*/) {
    const record = store.get(payload.dataID);
    if (!record) return;
    const isClient = typeof window !== 'undefined';
    // value first, then key. Confusing, but I'm sure there's a good reason for it
    record.setValue(isClient, payload.handleKey);
  },
};

function handlerProvider(handle /*: string */) /*: Handler */ {
  switch (handle) {
    case 'isClientFetched':
      return isClientFetchedHandler;
    default:
      return DefaultHandlerProvider(handle);
  }
}

const environment = new Environment({
  handlerProvider,
  network: Network.create(fetchQuery)
});

The handler will run when a fetch, like the one triggered by useLazyLoadQuery , adds data to the store, but not when we add records directly to the store with store.publish.

On the server, the isClientFetched field will be set to false. It will only become true when we fetch the query on the client.

The full code for this is in the OneBlog repo. We use isClientFetched on OneBlog to determine if we should show a welcome page to the author before they have published any articles.

You can deploy your own blog backed by GitHub issues on Vercel.

Deploy with Vercel