Typed GraphQL with react-query & graphql-request

While our front-end at MagicBell is a React/TypeScript application, we write our back-end in Ruby. This article explains how I've connected our client to the back-end and how we guard against breaking API changes.

GraphQL Code Generator

Let's get the obvious choice out of the way first. graphql-codegen. It's a no-brainer. This tool enables us to generate TypeScript types based on a GraphQL schema. Think typed queries, mutations, fragments, and object types.

Having types generated based upon your GraphQL schema means that TypeScript will inform you when the back-end (GraphQL API) introduces a breaking change. As a front-end engineer, that's what I want!

GraphQL Code Generator can generate fully typed React hooks if you tell it to, but I'm a fan of keeping things simple and thereby of their TypedDocumentNode approach. This variant is unaware of the GraphQL client that you're using. In other words, it's not tied to react-apollo (or alternative).

To get that up and running, you'll need to install a few dev dependencies:

npm i -D \
  @graphql-codegen/cli \
  @graphql-codegen/typed-document-node \
  @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations \
  @graphql-typed-document-node/core \

And now the more exciting part, our codegen config (codegen.yml). We don't have a schema stored in code, and because the back-end is in Ruby, graphql-codegen won't be able to extract the types out of the back-end source files either. So instead, we provide our GraphQL endpoint. It's an easy setup, but the one downside is that our server must be running when we want to generate new types.

schema:
  - http://localhost:3000/graphql:
      headers:
        X-MAGICBELL-API-KEY: "${MAGICBELL_API_KEY}"
documents: "./app/javascript/src/graphql/**/*.graphql"
generates:
  ./app/javascript/src/graphql/generated.ts:
    plugins:
      - typescript
      - typescript-operations
      - typed-document-node

That's it. Codegen will read the .env file from the project root and use that to populate the API headers. documents is the path where the GraphQL queries are stored. I'll come back to those later.

Graphql Client

I'm not going with the famous @apollo/client because it's too much for our dashboard. Besides, we're migrating from REST to GraphQL, meaning we'll need to deal with both for some time. As React Query is sublime in cache management, and can be used for both REST and GraphQL, we'll be using that.

I have considered swr instead of react-query, as it's smaller, but they lack some fundamentals that we need. Think clear state indicators or even a (solid) solution to manage mutations.

For the fetching part, we'll use graphql-request. It has almost as many installs/month as apollo, but it's way smaller and doesn't have as many open issues.

TypedDocumentNode and graphql-request

Now the "tricky" part, we need graphql-request to use our generated TypedDocumentNode. For that, I've created a custom hook:

import { useCallback } from 'react';
import { request } from 'graphql-request';
import { RequestDocument } from 'graphql-request/dist/types';
import { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { useCurrentUser } from '../context';

export function useGraphqlRequest() {
  const { apiKey } = useCurrentUser();

  return useCallback(
    <TDocument = any, TVariables = Record<string, any>>(
      document: RequestDocument | TypedDocumentNode<TDocument, TVariables>,
      variables?: TVariables,
    ) =>
      request<TDocument, TVariables>('/graphql', document, variables, {
        'X-MAGICBELL-API-KEY': apiKey,
      }),
    [apiKey],
  );
}

That's the magic sauce that will power it all. It returns a graphql-request client and uses TypedDocumentNode to infer types from the query. It also provides some defaults to graphql-request. Even without the type annotations, this would be a helpful hook that prevents setting options (like the header) in multiple places throughout our code.

Writing queries

And with this setup, we have a way to query the back-end with confidence. To create a new query, I'll write a GraphQL definition in a *.graphql file somewhere in the path declared by codegen.yml#documents.

For example, this logs query:

query logMessage($id: ID!) {
  log(id: $id) {
    id
    createdAt
    user {
      firstName
      lastName
      email
    }
    notification {
      title
      content
      actionUrl
    }
  }
}

Then run npx graphql-codegen, and consume the generated types in my query hook that I compose using react-query:

import { useQuery } from 'react-query';
import { useGraphqlRequest } from './useGraphqlRequest';
import { LogMessageDocument, LogMessageQuery } from './generated';

type UseLogMessageOptions = {
  logId?: LogMessageQuery['log']['id'];
};

export function useLogMessage({ logId }: UseLogMessageOptions) {
  const request = useGraphqlRequest();

  return useQuery<LogMessageQuery['log']>(
    ['log-message', logId],
    () => request(LogMessageDocument, { id: logId }).then((x) => x.log),
    { enabled: logId != null },
  );
}

Lastly, we'll consume that hook in our component, to fetch the data that we need:

function LogDetails({ logId }: LogDetailsProps) {
  const { data, status } = useLogMessage({ logId });

And that's it. data is fully typed. Suppose the back-end engineers introduce a change that isn't compatible with the current front-end, or I'm consuming data that never existed in the first place. In that case, TypeScript will notify us by throwing errors as part of the checks that run against our pull requests.

Recap

We type our GraphQL queries using graphql-codegen and use react-query to manage server/query state. graphql-request is the glue between codegen and react-query, a typed fetch for GraphQL, if you want. And with this setup, I have reduced the chance of breaking our GraphQL queries at MagicBell.

Liked this article?

If you made it to here, please share your thoughts on BlueSky, or leave a comment below.