How we built a student project platform using GraphQL, React, Golang, Ory Kratos and Kubernetes, part 2: Typesafe GraphQL client

peteole

Ole Petersen

Posted on March 14, 2022

How we built a student project platform using GraphQL, React, Golang, Ory Kratos and Kubernetes, part 2: Typesafe GraphQL client

After explaining how we built our student project graphql API in a typesafe way, we will continue by having a look at the client side.

In terms of technology we use React (typescript) with the Apollo GraphQL Client as well as a code generator for type safety.

Apollo client

The Apollo client has some serious advantages:

  • The whole application state is kept in an advanced cache which requires only minimal configuration. This minimizes network traffic and keeps the UI elements in sync.
  • Nice integration with React
  • Well customizable

This is the basic usage:

// main.tsx
import App from './App'
import {
  ApolloProvider,
  ApolloClient
} from "@apollo/client";
export const client = new ApolloClient({
    uri: 'https://huddle.hsg.fs.tum.de/api/query',
    cache: new InMemoryCache(),
});
ReactDOM.render(
  <React.StrictMode>
    <ApolloProvider client={client}> //inject the client here
        <App/>
    </ApolloProvider>
  </React.StrictMode>,
  document.getElementById('root')
)
Enter fullscreen mode Exit fullscreen mode
// App.tsx
import { gql, useQuery } from '@apollo/client';
const App: React.FC = () => {
    const [projectId, setProjectId]=useState("")
    const {data} = useQuery(gql`
        query($id: ID!){
            getProject(id: $id) {
                name
                description
            }            
        }
    `,{variables:{id:projectId}}
    )
    return (
        <div>
            Enter project ID to explore
            <input onChange={(newId)=>{
                setProjectId(newId)
            }}>
            <div>
                <p>Project name: {data.getProject.name}</p>
                <p>Project description: {data.getProject.description}</p>
            </div>
        </div>
    )
}
export default App
Enter fullscreen mode Exit fullscreen mode

This little code will allow you to explore huddle projects!

Introduce typesafety

The code above already looks nice, but the data returned and the variables used in the useQuery are untyped. To fix this issue we will introduce yet another code generator:

With GraphQL Code Generator you define the queries in a document and let the code generator generate typesafe versions of the useQuery apollo hook (using the GraphQL schema of your API).

The setup is simple:

yarn add graphql
yarn add @graphql-codegen/cli
yarn graphql-codegen init
yarn install # install the choose plugins
yarn add @graphql-codegen/typescript-react-query
yarn add @graphql-codegen/typescript
yarn add @graphql-codegen/typescript-operations
Enter fullscreen mode Exit fullscreen mode

Now let's configure the code generator by editing the newly created file codegen.yml:

overwrite: true
schema: https://huddle.hsg.fs.tum.de/api/query # link your API schema here
documents: operations/* #define graphql queries you want to use react here
generates:
  src/schemas.ts: #the generated code will end up here
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-react-apollo"
      - typescript-apollo-client-helpers
Enter fullscreen mode Exit fullscreen mode

You can now add operations you want to use in your components in operations/projectOperations.gql:

query getProjectById($id: ID!) {
  getProject(id: $id) {
    id
    name
    description
    creator {
      username
      id
    }
    location {
      name
    }
    saved
    tags
...
  }
}
Enter fullscreen mode Exit fullscreen mode

Installing the GraphQL VSCode extension and creating the graphql.config.yml file with the following content

schema:
  - https://huddle.hsg.fs.tum.de/api/query
documents: ./operations/*.graphqls
Enter fullscreen mode Exit fullscreen mode

will even give you intellisense in the operations
Gql intellisense

Executing yarn run graphql-codegen will do all the magic for you!
Let's say we want to implement the ProjectDetail-component which displays details of the project with the id passed in the props. We can now import the useGetProjectByIdQuery hook!

import { useGetProjectByIdQuery, ...} from '../schemas';
import { ImageGallery } from '../shared/ImageGallery';
import ReactMarkdown from 'react-markdown';
...
export type ProjectDetailProps = {
    id: string
    onBackClicked?: () => void
}
const ProjectDetail: React.FC<ProjectDetailProps> = (props) => {
    const projectResult = useGetProjectByIdQuery({ variables: { id: props.id } });
 ...
    if (props.id == "") return <div></div>
    if (projectResult.loading) return <div className='project-detail'>Loading...</div>
    if (projectResult.error) return <div className='project-detail'>Error: {projectResult.error.message}</div>
    const images = projectResult.data?.getProject?.images
    return (
        <div className="project-detail">
...
            <h1>{projectResult.data?.getProject?.name}</h1>
...
            <ReactMarkdown >{projectResult.data?.getProject?.description || "(no description provided)"}</ReactMarkdown>
            {images && images.length > 0 ? <div >
                <ImageGallery images={images.map(image => ({
                    url: image.url,
                    description: image.description || undefined
                }))} />
            </div> : null}
            <p>Created by {projectResult.data?.getProject?.creator.username}</p>
...
        </div>
    );
}

export default ProjectDetail;
Enter fullscreen mode Exit fullscreen mode

Note that this hook is fully typed:
Typed Hook
Nice! It's this easy to make an API end-to-end typesafe!

Now as a bonus let's have a look at how to customize the cache to our needs.
Let's say we update a project at some place in the code. We want Apollo to sync the update to all the components we used in the code. To do so, we need to tell Apollo somehow to decide which Project objects correspond to the same object (and must therefore be updated) and how to apply updates to the cache for instance if only a few fields are refetched with a new value. This is done by passing a TypePolicies object to the Apollo client cache. The type of this object is also generated by our code generator. So let's do it:

// main.tsx
import App from './App'
import { StrictTypedTypePolicies } from "./schemas";
import { offsetLimitPagination } from "@apollo/client/utilities";
import {
  ApolloProvider,
  ApolloClient
} from "@apollo/client";
const typePolicies: StrictTypedTypePolicies={
    Project:{
        keyFields:["id"], // treat Project objects with the same id as the same project
        merge(existing, incoming) { //merge new projects on old projects. This may be more advanced.
            return { ...existing, ...incoming };
        }
    },
     Query:{
        fields:{
            searchProjects: offsetLimitPagination()
        }
    }
}
export const client = new ApolloClient({
    uri: 'https://huddle.hsg.fs.tum.de/api/query',
    cache: new InMemoryCache({typePolicies}),
});
ReactDOM.render(
  <React.StrictMode>
    <ApolloProvider client={client}> //inject the client here
        <App/>
    </ApolloProvider>
  </React.StrictMode>,
  document.getElementById('root')
)
Enter fullscreen mode Exit fullscreen mode

The custom merge function can also be used to concatenate parts of an infinite feed of results to one list. Since the query uses "offset" and "limit" as parameters, we can use the existing merger function offsetLimitPagination provided by Apollo, which merges results by concatenating the result lists according to the offset and limit parameters.
Like this you can trigger a fetching of more results and append them to current result list flawlessly when the user scrolls towards the end of the list.

For instance we have a searchProject function which receives an offset and a limit of results. This is how we implement an infinite scroll bar:

//HomePage.tsx
import { useRef, useState } from 'react';
import HomeHeader from '../home-header/home-header';
import ProjectList from '../project-list/project-list';
import { useSearchProjectsQuery } from '../schemas';
import "./home-page.css"

function HomePage() {
    const [searchString, setSearchString] = useState("");
...
    const projectData = useSearchProjectsQuery({ variables: { searchString: searchString, limit: 10, options: getOptions(category) } })
    const lastRefetchOffset = useRef(-1)// keep track of the last offset we refetched to see if currently new data is loading already
    const onScrollToBottom = () => {
        if (lastRefetchOffset.current === projectData.data?.searchProjects?.length) {
            return;// already loading, so do nothing
        }
        lastRefetchOffset.current = projectData.data?.searchProjects?.length || -1;
        projectData.fetchMore({
            variables: {
                offset: projectData.data?.searchProjects?.length,
                limit: 10,
                options: getOptions(category),
                searchString: searchString
            }
        })
    }
    const entries = projectData.data?.searchProjects.map(p => ({
        description: p.description,
        id: p.id,
        name: p.name,
        ...)) || []
    return (
        <div style={{ position: "relative" }}>
            <HomeHeader onSearchStringChange={(searchString: string) => {
                setSearchString(searchString) // HomeHeader contains a search bar whose updates we can subscribe to here
            }} .../>
            <div className='home-bottom'>
                <ProjectList entries={entries} onScrollToBottom={onScrollToBottom} />
            </div>
        </div>
    );
}

export default HomePage;
Enter fullscreen mode Exit fullscreen mode

I hope you liked this collection of useful tips for using GraphQL on the client side. Feel free to comment!

Stay tuned for the next part where I will discuss how we handele authentication with Ory Kratos!

💖 💪 🙅 🚩
peteole
Ole Petersen

Posted on March 14, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related