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
.graphql
files, we tell codegen to look for.graphql
files undersrc
folder. - All the generated types will be under the
src/graphql/generated.ts
file, 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.tsx
component we query for all the Todos and display them using cards in a 3-column grid. For the cards we useTodoCard
component. - When the user clicks on the
Create Todo
button we show theTodoForm
component inside the Modal. - Also, when the user clicks on the
Edit
icon inside theTodoCard
we show the sameTodoForm
component 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 state
we will be using mantine form'screateFormContext
method. - 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
showForm
to open and close the form modal. - Next
isEditing
to 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
&TodoForm
component.
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 theTodoCard
it will also re-render when we interact with the form. - Therefore, I handled all the form related logic in the
App & TodoForm
component and usinguseCallback
made sure that theonEditTodo & onDeleteTodo
do not get re-created on every app render, this prevents theTodoCard
from 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 & deleteTodo
mutations, and whenever we have a successful mutation, we will add the data to theGetTodos
query cache and it will be reflected on the UI with no network requests. - Finally, we remove the
urql
cacheExchange 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)