DEV Community

Arsalan Ahmed Yaldram
Arsalan Ahmed Yaldram

Posted on

Getting Started, GraphQL & Node.js in 2023 - React urql

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.

todos-frontend

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
Enter fullscreen mode Exit fullscreen mode

After the react project is setup lets now install urql -

yarn add urql
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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 under src 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
 },
Enter fullscreen mode Exit fullscreen mode

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 componentsApp.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 use TodoCard component.
  • When the user clicks on the Create Todo button we show the TodoForm component inside the Modal.
  • Also, when the user clicks on the Edit icon inside the TodoCard we show the same TodoForm 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's createFormContext 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
Enter fullscreen mode Exit fullscreen mode

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">>();
Enter fullscreen mode Exit fullscreen mode
  • 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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
  • 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • 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 the TodoCard 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 using useCallback made sure that the onEditTodo & onDeleteTodo do not get re-created on every app render, this prevents the TodoCard 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 
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode
  • We are listening to the addTodo & deleteTodo mutations, and whenever we have a successful mutation, we will add the data to the GetTodos 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)