Introduction
This is part four of our tutorial series. In the previous tutorial we created a Todos GraphQL server, using code first approach. In this tutorial we will be creating a simple frontend using React & urql that will query our GraphQL server.
Overview
Back when I used to work with GraphQL on the client, I used to use Apollo client, and used to manually add all the types for all queries & mutations. Today we have graphql codegen that automatically creates types for all GraphQL operations. We will also be using urql as the GraphQL client library.
This series is not recommended for beginners some familiarity and experience working with Nodejs, GraphQL & Typescript is expected. In this tutorial we will cover the following :-
- Bootstrap the react project.
 - Setup Codegen.
 - Build the App.
 - Avoid network requests, use the Cache.
 
All the code for this tutorial is available on this sandbox.
Step One: Bootstrap project
First create the react project, we will be using vite -
yarn create vite todos-urql --template react-ts
After the react project is setup lets now install urql -
yarn add urql
Now instantiate the urql client, under main.tsx file -
import { createClient, Provider as UrqlProvider, cacheExchange, fetchExchange } from "urql";
const client = createClient({
  url: 'http://localhost:4000/graphql',
  exchanges: [cacheExchange, fetchExchange]
})
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <UrqlProvider value={client}>
       <App />
    </UrqlProvider>
  </React.StrictMode>
);
- We first create the client, and pass the 
cache & fetch exchanges, they will take care of caching our queries and re-fetching them to update our cache. - We then wrap our App with the Provider and pass the client.
 
For the UI and Forms we will be using mantine -
yarn add @mantine/core @mantine/hooks @mantine/form @emotion/react @tabler/icons-react
Under main.tsx wrap the app with MaintineProvider -
import { MantineProvider } from "@mantine/core"; 
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <UrqlProvider value={client}>
      <MantineProvider withGlobalStyles withNormalizeCSS>
        <App />
      </MantineProvider>
    </UrqlProvider>
  </React.StrictMode>
);
Step Two: Setting up codegen
Codegen makes it easy to work with TypeScript and GraphQL, it will create all the necessary types for our queries & mutations and all our response data from queries and mutations will also be typed. From the terminal install -
yarn add -D ts-node @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations
@graphql-codegen/typed-document-node
From the root of the project create a codegen.yml file -
schema: http://localhost:4000/graphql
documents: "./src/**/*.graphql"
generates:
  ./src/graphql/generated.ts:
    plugins:
      - typescript
      - typed-document-node
      - typescript-operations
- We first have our schema url, so that codegen can introspect it.
 - We will be writing our queries & mutations in 
.graphqlfiles, we tell codegen to look for.graphqlfiles undersrcfolder. - All the generated types will be under the 
src/graphql/generated.tsfile, finally we have all the plugins. 
Under src/graphql create a new file todos.graphql and add all our queries and mutations here -
query GetTodos {
  todos {
    id
    task
    tags
    status
    description
    comments {
      body
      id
    }
  }
}
mutation DeleteTodo($input: DeletesTodoInput!) {
  deleteTodo(input: $input) {
    id
  }
}
mutation createTodo($input: CreateTodoInput!) {
  addTodo(input: $input) {
    id
    description
    status
    tags
    task
  }
}
mutation editTodo($input: EditTodoInput!) {
  editTodo(input: $input) {
    id
    description
    status
    tags
    task
  }
}
Now under package.json add the following scripts -
"scripts": {
    "predev": "npm run codegen",
    "codegen": "graphql-codegen",
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
 },
Whenever we run yarn dev the predev script will run and generate the types for us.
Step Three: Building our App
Instead of creating the app in bits and pieces, I will paste the whole code here and explain -
- We have 3 components
App.tsx,TodoCard&TodoForm. - In the 
App.tsxcomponent we query for all the Todos and display them using cards in a 3-column grid. For the cards we useTodoCardcomponent. - When the user clicks on the 
Create Todobutton we show theTodoFormcomponent inside the Modal. - Also, when the user clicks on the 
Editicon inside theTodoCardwe show the sameTodoFormcomponent this time with all the fields pre-filled for editing. - Basically, we need to share the state between all these 3 components.
 - For the 
form statewe will be using mantine form'screateFormContextmethod. - For the Modal open close state, we will be using a global state management legend app.
 
First from the terminal install -
yarn add @legendapp/state
Under src/store.ts paste the following -
import { observable } from "@legendapp/state";
import { createFormContext } from "@mantine/form";
import { Todo } from "./graphql/generated";
export const store = observable({ showForm: false, isEditing: false });
export const [FormProvider, useFormContext, useForm] = createFormContext<Omit<Todo, "__typename" | "comments">>();
- We created 2 pieces of state, one 
showFormto open and close the form modal. - Next 
isEditingto know whether we are editing a Todo so that we can change the Modal title from create to edit and also fire the correct mutation when we submit the form. - Finally, we create a formContext to share the form state between the 
App&TodoFormcomponent. 
Now under src/TodoCard.tsx paste the following -
import { IconEdit, IconTrash } from "@tabler/icons-react";
import {
  Card,
  Stack,
  Group,
  Text,
  ActionIcon,
  Badge
} from "@mantine/core"
import { TaskStatus, Todo } from "./graphql/generated";
type TodoCardProps = {
  todo: Todo
  onEditTodo: (todo: Todo) => void;
  onDeleteTodo: (todoId: string) => void;
}
export function TodoCard({ todo, onEditTodo, onDeleteTodo }: TodoCardProps) {
  return (
    <Card h="100%" shadow="sm" p="lg" radius="md" withBorder>
      <Stack key={todo.id}>
        <Stack>
          <Group position="apart">
            <Text size="lg" weight={500}>
              {todo.task}
            </Text>
            <Group>
              <ActionIcon onClick={() => onEditTodo(todo)}>
                <IconEdit color="blue" size={16} />
              </ActionIcon>
              <ActionIcon onClick={() => onDeleteTodo(todo.id)}>
                <IconTrash color="red" size={16} />
              </ActionIcon>
            </Group>
          </Group>
          <Group>
            <Badge color={getStatusColor(todo.status)}>
              {todo.status}
            </Badge>
          </Group>
        </Stack>
        <Text size="sm" color="dimmed">
          {todo.description}
        </Text>
        <Group mt="sm">
          {todo.tags?.map((tag, index) => (
            <Badge key={index}>{tag}</Badge>
          ))}
        </Group>
      </Stack>
    </Card>
  )
}
function getStatusColor(status: TaskStatus) {
  if (status === TaskStatus.Pending) {
    return "red";
  } else if (status === TaskStatus.InProgress) {
    return "grape";
  } else {
    return "green";
  }
}
Similarly, under src/TodoForm.tsx paste the following -
import { useState } from "react"
import { useMutation } from "urql";
import { observer } from "@legendapp/state/react"
import {
  Group,
  Stack,
  Modal,
  Button,
  LoadingOverlay,
  TextInput,
  Textarea,
  Select,
  MultiSelect,
} from "@mantine/core";
import { store, useFormContext } from "./store"
import {
  TaskStatus,
  CreateTodoDocument,
  EditTodoDocument,
} from "./graphql/generated";
function Component() {
  const form = useFormContext();
  const [tags, setTags] = useState(["GraphQL", "Pothos"]);
  const [{ fetching: creatingTodo, }, createTodo] = useMutation(CreateTodoDocument);
  const [{ fetching: updatingTodo }, editTodo] = useMutation(EditTodoDocument);
  return (
    <Modal
      centered
      opened={store.showForm.get()}
      onClose={() => store.showForm.toggle()}
      title={`${store.isEditing.get() ? "Edit task" : "Create a new task"}`}
    >
      <form onSubmit={form.onSubmit(async (values) => {
        const task = {
          task: values.task,
          description: values.description,
          status: values.status,
          tags: values.tags,
        };
        try {
          if (store.isEditing.get()) {
            await editTodo({ input: { id: values.id, ...task } });
          } else {
            await createTodo({ input: task });
          }
        } catch (error) {
          console.log("onSubmit Error", error);
        } finally {
          store.showForm.set(false);
          store.isEditing.set(false)
          form.reset();
        }
      }
      )}>
        <LoadingOverlay visible={creatingTodo || updatingTodo} />
        <Stack spacing="sm">
          <TextInput
            autoFocus
            required
            placeholder="Learn GraphQL"
            label="Task"
            {...form.getInputProps("task")}
          />
          <Textarea
            required
            placeholder="Learn Code first GraphQL"
            label="Task Description"
            {...form.getInputProps("description")}
          />
          <Select
            label="Task Status"
            placeholder="Pick one"
            data={[
              TaskStatus.Pending,
              TaskStatus.InProgress,
              TaskStatus.Done,
            ]}
            {...form.getInputProps("status")}
          />
          <MultiSelect
            label="Tags"
            data={tags}
            placeholder="Select items"
            searchable
            creatable
            getCreateLabel={(query) => `+ Create ${query}`}
            onCreate={(query) => {
              setTags((current) => [...current, query]);
              return query;
            }}
            {...form.getInputProps("tags")}
          />
          <Group position="right">
            <Button w="150px" color="green" type="submit">
              {store.isEditing.get() ? "Edit" : "Create"}
            </Button>
          </Group>
        </Stack>
      </form>
    </Modal>
  )
}
export const TodoForm = observer(Component)
- Take a note that we are wrapping our component with the observer function, so legend state will re-render it whenever the global state changes.
 
Finally under the src/App.tsx paste the following -
import { useCallback } from "react"
import { useQuery, useMutation } from "urql";
import {
  Group,
  Grid,
  Loader,
  Stack,
  Button,
  Box,
  Text
} from "@mantine/core";
import { TaskStatus, Todo, DeleteTodoDocument, GetTodosDocument } from "./graphql/generated";
import { TodoForm } from "./TodoForm";
import { TodoCard } from "./TodoCard";
import { store, useForm, FormProvider } from "./store";
export function App() {
  const [{ fetching, data, error }] = useQuery({
    query: GetTodosDocument
  })
  const [_, deleteTodo] = useMutation(DeleteTodoDocument);
  const form = useForm({
    initialValues: {
      id: "",
      task: "",
      tags: [] as string[],
      status: TaskStatus.Pending,
      description: "",
    },
  });
  const onEditTodo = useCallback((todo: Todo) => {
    store.isEditing.set(true);
    form.setFieldValue("task", todo.task);
    form.setFieldValue("description", todo.description);
    form.setFieldValue("status", todo.status);
    form.setFieldValue("tags", todo.tags ?? []);
    form.setFieldValue("id", todo.id);
    store.showForm.set(true)
  }, [])
  const onDeleteTodo = useCallback((todoId: string) => {
    deleteTodo({ input: { id: todoId } })
  }, [])
  if (fetching) {
    return (
      <Stack h="100vh" align="center" justify="center">
        <Loader variant="bars" />
      </Stack>
    );
  }
  if (error) return <div>Error....</div>;
  return (
    <FormProvider form={form}>
      <Box p="md">
        <TodoForm />
        <Group position="right">
          <Button onClick={() => store.showForm.set(true)}>Create a new task</Button>
        </Group>
        {data?.todos.length !== 0 ? (
          <Grid p="lg">
            {data?.todos.map((todo) => (
              <Grid.Col key={todo.id} span={4}>
                <TodoCard
                  todo={todo}
                  onEditTodo={onEditTodo}
                  onDeleteTodo={onDeleteTodo}
                />
              </Grid.Col>
            ))}
          </Grid>
        ) : (
          <Stack h="100vh" align="center" justify="center">
            <Text weight={500}>You don't have any tasks!</Text>
          </Stack>
        )}
      </Box>
    </FormProvider>
  );
}
- We wrap the whole App with the 
Form Provider. 
One question may arise like why are we handling the onEdit and onDelete in the main app and not the TodoCard ?
- Given the fact that we are wrapping the whole app inside the FormContext whenever we type in the form fields all the components that are using the form context will re-render.
 - So, if we use 
const form = useFormContext()in theTodoCardit will also re-render when we interact with the form. - Therefore, I handled all the form related logic in the 
App & TodoFormcomponent and usinguseCallbackmade sure that theonEditTodo & onDeleteTododo not get re-created on every app render, this prevents theTodoCardfrom unnecessary re-renders. 
Now run the app using yarn dev and play with it.
Step Four: Caching
You might have noticed one thing, when we edit or delete a Todo it is reflected on the UI, but when we create a new Todo it does not show. Also, open the network tab and you will see that urql is refetching the todos after every mutation.
I don't want this, I want to update the local cache after the mutation, I don't want un-necessary network requests. For this we need to use @urql/exchange-graphcache library -
yarn add @urql/exchange-graphcache 
Under main.tsx paste the following -
import React from "react";
import ReactDOM from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { createClient, Provider as UrqlProvider, fetchExchange } from "urql";
import { cacheExchange } from "@urql/exchange-graphcache";
import { App } from "./App";
import { GetTodosDocument, Todo } from "./graphql/generated";
const cache = cacheExchange({
  updates: {
    Mutation: {
      addTodo(result, _args, cache) {
        cache.updateQuery({ query: GetTodosDocument }, (data) => {
            data?.todos?.push({...result?.addTodo as Todo, comments: []});
            return data;
        })
      },
      deleteTodo(result: { deleteTodo: { id: string } }, _args, cache) {
        cache.updateQuery({ query: GetTodosDocument }, (data) => {
          return {
            ...data,
            todos: data?.todos?.filter((todo: Todo) => todo.id !== result?.deleteTodo?.id) || []
          }
        })
      }
    }
  }
})
const client = createClient({
  url: 'http://localhost:4000/graphql',
  exchanges: [cache, fetchExchange]
})
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <UrqlProvider value={client}>
      <MantineProvider withGlobalStyles withNormalizeCSS>
        <App />
      </MantineProvider>
    </UrqlProvider>
  </React.StrictMode>
);
- We are listening to the 
addTodo & deleteTodomutations, and whenever we have a successful mutation, we will add the data to theGetTodosquery cache and it will be reflected on the UI with no network requests. - Finally, we remove the 
urqlcacheExchange function from the exchanges array and pass our cache to it. 
Run the project, open the network tab and check if our cache is working as expected for each mutation there should not be any network request for todos query and the UI should also be updated.
Conclusion
In this tutorial we build a simple frontend for our GraphQL backend using the latest tools available. We used codegen to avoid creating types manually, also we used the latest atomic state management library legendapp instead of passing props all over. Finally, we made use of the local cache provided by urql and avoided unnecessary network requests to the server. Now that I have explored GraphQL with Node.js my next goal is to try out trpc and compare it with GraphQL. Until next time PEACE.

    
Top comments (0)