Current React Progressive Web App

My current setup uses a custom cdk construct that I built to assist with generating my graphql schema from Amplify defined directives. I then take the generated schema and use a combination of graphql-code-generator and amplify-graphql-docs-generator to generate my TypeScript types, queries, mutations and subscriptions for the frontend. I then built a singleton api class to use for querying data, something like this:

import { API as AmplifyAPI, graphqlOperation } from 'aws-amplify';
import { GraphQLResult, GRAPHQL_AUTH_MODE } from '@aws-amplify/api/lib/types';
import * as queries from '../graphql/queries';
import * as types from '../types';

let instance: API | undefined = undefined;

export class API {
    protected isSignedIn: boolean = false;

    constructor() {
        this.isSignedIn = false;
    }

    static getInstance(): API {
        if (!instance) instance = new API();
        return instance;
    }

    static updateIsSignedIn(signedIn: boolean): void {
        if (!instance) instance = new API();
        instance.isSignedIn = signedIn;
    }

    public async listPosts(queryArgs: types.QueryListPostsArgs): Promise<Array<types.Maybe<types.Post>>> {
        const response = await this.genericQuery(queries.listPosts, queryArgs);
        return response.data?.listPosts;
    }

    private async genericQuery(query: string, variables?: any) {
        const operation = {
            authMode: this.isSignedIn ? GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS : GRAPHQL_AUTH_MODE.AWS_IAM,
            ...graphqlOperation(query, variables)
        }

        return await AmplifyAPI.graphql(operation) as GraphQLResult<any>;
    }
}

Since I support unauthorized access for some of the queries I wanted a simple way to switch between IAM and cognito auth modes. This allows me to trigger off of the Amplify onAuthUIStateChange and switch my authorization mode instead of determining the value from state or passing it in for each query.

React Query

Recently I’ve been going through my PWA in an attempt to improve user experience. I’m pretty new to frontend development (just started in February 2020) and I’ve landed on react-query (it’s even better now that v3 is out!). I really did not want to have to go through and update every single query or provide a wrapper for every call in my API class. I did some digging and found the typescript-react-query plugin for graphql-code-generator! I began hatching an idea to use my current generation tools, define a custom “fetcher”, and have all of my useQuery hooks wrapped for me

GraphQL Codegenator

Previously I had been using the programmatic usage for graphql-code-generator. I prefer this since I can bundle it up and use only the features I want, as well as merge schemas, add directives and scalars, etc. However, this demonstration uses the cli instead while I try to figure out why the plugins won’t work when I call them programmatically.

Custom Fetcher

The typescript-react-query plugin can use fetch, graphql-request, or a custom fetcher when determining how to retrieve the data for the custom hooks. Since we are using amplify-js libraries we need to build our own fetcher. You can use this example if you need multi-auth support or write a simpler one if you do not. You will notice it is almost exactly the same as the API class above but slightly modified for our use here:

// https://github.com/kcwinner/cdk-appsync-react-demo/blob/main/frontend/src/lib/fetcher.ts
import { API as AmplifyAPI, graphqlOperation } from 'aws-amplify';
import { GraphQLResult, GRAPHQL_AUTH_MODE } from '@aws-amplify/api/lib/types';

let instance: API | undefined = undefined;

export function amplifyFetcher<TData, TVariables>(query: string, variables?: TVariables) {
    return async (): Promise<TData> => {
        const api = API.getInstance();
        const response = await api.query(query, variables);
        return response.data;
    }
}

export class API {
    protected isSignedIn: boolean = false;

    constructor() {
        this.isSignedIn = false;
    }

    static getInstance(): API {
        if (!instance) instance = new API();
        return instance;
    }

    static updateIsSignedIn(signedIn: boolean): void {
        if (!instance) instance = new API();
        instance.isSignedIn = signedIn;
    }

    public async query(query: string, variables?: any) {
        const operation = {
            authMode: this.isSignedIn ? GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS : GRAPHQL_AUTH_MODE.AWS_IAM,
            ...graphqlOperation(query, variables)
        }

        return await AmplifyAPI.graphql(operation) as GraphQLResult<any>;
    }
}

Codegen Config

Setting up your graphql-code-generator is pretty simple. We define our schema glob pattern (so we can load amplify base types and our appsync schema), where the output should be saved (src/lib/api.ts), which plugins to use, and our config.

# https://github.com/kcwinner/cdk-appsync-react-demo/blob/main/frontend/codegen.yml
overwrite: true
schema: "./*.graphql"
documents: "src/graphql/*.ts"
generates:
  src/lib/api.ts:
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-react-query"
    config:
      maybeValue: 'T | null | undefined'
      fetcher: '../lib/fetcher#amplifyFetcher'

Once all your files .graphql files are in place you can call graphql-codegen --config codegen.yml. This will generate your hooks and put them at the location specified in your config.

Using The Hooks

Now inside of our component we can import the custom hook to query our data!

// https://github.com/kcwinner/cdk-appsync-react-demo/blob/main/frontend/src/components/posts.tsx
import React, { useState } from 'react';
import { useMutation } from 'react-query';
import { Auth } from '@aws-amplify/auth';

import { useListPostsQuery, CreatePostInput, CreatePostDocument, Post } from '../lib/api';
import { API } from '../lib/fetcher';

export function Posts() {
  const { data, isLoading, refetch } = useListPostsQuery(null, {
    refetchOnWindowFocus: false
  });

  // useCreatePostMutation isn't working correctly right now - see limitations below
  const [createPost] = useMutation(async (input: CreatePostInput) => {
    const result = await API.getInstance().query(CreatePostDocument, { input });
    return result.data?.createPost as Post;
  });

  ...
  ...
}

Limitations

  • I have an open pull request for adding react-query v3 support.
    • Technically v3 still works if you swap out MutationConfig and QueryConfig in the generated file for UseMutationOptions and UseQueryOptions respectively but it would be annoying to do each time (I guess you could do it programmatically)
  • Mutations do not currently work well. After some digging, the custom useXyzMutation hooks require variables to be passed in when instantiated (instead of when calling mutate) it passes in wrong values to the api. My PR fixes this as well

Wrapping Up

One thing to note is you can use this with your current AppSync setup regardless if you are using Amplify, the cdk-appsync-transformer or some other way of deploying your schema.

Make sure to try out the demo or watch the video!

References