Originally published at [Shuttle](https://www.shuttle.rs/blog/2024/02/13/clerk-in-react)
This two-part article covers how we can build an Issue tracking application using React for the frontend, and Shuttle and Actix web for the backend. It also uses Clerk for authentication in the frontend and protecting the Rest APIs in the backend.
This article covers the React frontend, and the first part covers the Rust backend.
Here is the source code of the complete project if you want to follow along, and a link to the demo.
Overview
In the frontend
directory of the template we've been building from in Part 1, letโs explore some of the React libraries that the template has provided us.
Shadcn UI is a collection of reusable components that uses Tailwind CSS to build its components with great accessibility.
SWR is used to create hooks for data fetching and mutations by consuming the APIs we have created in the backend.
Lucide React has a wide range of icons that sits just right with Shadcn UI.
Build the frontend
Setting Environment Variable
You can find a .env.example
file copy the content, create a new file .env
in the same path, and paste the content in it. From your Clerk dashboard get the PUBLISHABLE_KEY
and assign it to the variable VITE_CLERK_PUBLISHABLE_KEY
in the .env
file:
VITE_CLERK_PUBLISHABLE_KEY=pk_test_YW1xxxxxxxxxxxxxxxxxxxxxxxxxxxcy5kZXYk
Adding components from Shadcn UI
We already have table and avatar components in the /frontend/components/ui
directory but we need a few more components to build this application. Run this command to add the components:
npx shadcn-ui@latest add badge button card dialog form input label select sonner
Schema
We need to represent the structure of the data we are going receive in response from the backend to keep our frontend application type safe and more predictable.
Create /frontend/lib/schema.ts
and add IssueSchema
and UserSchema
interfaces:
export interface IssueSchema {
id: string | undefined,
title: string;
description: string;
status: "todo" | "inprogress" | "done" | "backlog";
label: "bug" | "feature" | "documentation";
author: string;
}
export interface UserSchema {
id: string,
first_name: string,
last_name: string,
username: string,
profile_image_url: string,
}
In the IssueSchema
, we have predefined the label and status field value because this helps keep different parts of our application well aware of the incoming data and restricts invalid data input or output.
Store
We will need to create two stores using Zustand. First, install Zustand:
npm install zustand
- Store for issue
id
that has to be updated/edited
import { create } from 'zustand'
type IssueStore = {
edit_issue_id: string,
setEditIssueId: (id: string) => void
}
export const useIssueStore = create<IssueStore>((set) => ({
edit_issue_id: "",
setEditIssueId: (id: string) => set(() => ({ edit_issue_id: id })),
}))
- Store for closing and opening the issue modal
type IssueModalStore = {
isOpen: boolean,
setOpen: () => void
setClose: () => void
}
export const useIssueModalStore = create<IssueModalStore>((set) => ({
isOpen: false,
setOpen: () => set({ isOpen: true }),
setClose: () => set({ isOpen: false }),
}))
Custom hooks
We will create the custom hooks using useSWR
and userSWRMutation
so that we can revalidate the data after any mutation happens and cache the stale data.
Create /frontend/lib/hooks.ts
and let's start integrating all the APIs we have created in the backend. First let's bring all the required schema, hooks, and components into the scope:
import useSWR, { Fetcher, useSWRConfig } from "swr"
import useSWRMutation from 'swr/mutation'
import { IssueSchema, UserSchema } from "./schema";
import { toast } from "sonner";
import { useIssueModalStore } from "./store";
Get user hook
This hook will consume the get user by id
API we have created before. The first parameter of useSWR
is the key which is used as an identifier to cache the data and revalidate the data using that key, this key can be a subset of the URL path which can be passed to the fetcher function to fetch or mutate data as well. To know more about the arguments follow this documentation.
The hook returns an object where the isLoading
property is a boolean property that sets to true while data is being fetched and a data property that is the returned value of the hook.
export const useGetUser = (user_id: string | undefined) => {
const fetcher: Fetcher<UserSchema, string> = (url) => fetch(url).then(res => res.json())
const { isLoading, data } = useSWR(`/api/user/${user_id}`, fetcher);
return { isLoading, data };
}
Get issues hook
Fetches all the issues for the issues table, consuming get issues API, the response of this API on success will be an array of issues, thatโs why we have passed IssuesSchema[]
to the Fetcher
type:
export const useGetIssues = () => {
const fetcher: Fetcher<IssueSchema[], string> = (url) =>
fetch(url).then((res) => res.json())
const { isLoading, data } = useSWR('/api/issues', (url) => fetcher(url));
return { isLoading, data };
}
Get issue by id
hook
This hook uses the get issue by id
API and returns the one single record that matches that id
additionally, we have added a condition to call the fetcher
function only when id
is not empty:
export const useGetIssue = (id: string) => {
const fetcher: Fetcher<{ data: IssueSchema }, string> = (url) =>
fetch(url).then((res) => res.json())
const { data } = useSWR(() => id !== "" ? `/api/issue/${id}` : null, fetcher);
return { data: data?.data }
}
Create issue hook
This hook returns a trigger
method from useSWRMutation
hook which takes the data to be sent in the request body of the create issue API and an isMutating
boolean property which we can use to show a loading animation and disable any mutation process while this property is true.
We use the setClose
method from the useIssueModalStore
to close the issue modal once the issue has been created successfully.
The data of the payload should match the IssueSchema
but it should not have the id
field as it is a new record, so to remove id
field we use Omit<IssueSchema, "id">
which creates a new type with the id
field omitted.
export const useCreateIssue = () => {
const { setClose } = useIssueModalStore();
const create = (url: string, { arg }: { arg: Omit<IssueSchema, "id"> }) => fetch(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(arg),
}).then(res => res.json())
const { mutate: revalidateIssuesList } = useSWRConfig();
const { isMutating, trigger } = useSWRMutation("/api/issue", create, {
onSuccess() {
toast.success("Issue has been created.");
setClose();
revalidateIssuesList("/api/issues")
},
onError(err) {
if (err.message) {
toast.error(err.message);
} else {
toast.error("Failed to create issue");
}
},
})
return { isMutating, trigger }
}
๐จ๏ธ We need to specify the content type header as application/json
in the fetch calls because by default fetch API sets the content type header to text/plain
which causes an incorrect content error as the backend we have created expects a JSON payload.
Update/Edit issue hook
This hook consumes the update issue by id
API and similar to the create issue hook it also returns isMutating
property and trigger
method.
We use the setClose
method from the useIssueModalStore
to close the issue modal once the issue has been updated successfully.
export const useEditIssue = () => {
const { setClose } = useIssueModalStore();
const edit = (url: string, { arg }: { arg: IssueSchema }) => fetch(`${url}/${arg.id}`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(arg),
}).then(res => res.json())
const { mutate: revalidateIssuesList } = useSWRConfig();
const { isMutating, trigger } = useSWRMutation("/api/issue", edit, {
onSuccess() {
toast.success("Issue has been updated.");
setClose();
revalidateIssuesList("/api/issues")
},
onError(err) {
if (err.message) {
toast.error(err.message);
} else {
toast.error("Failed to update issue");
}
},
})
return { isMutating, trigger }
}
Delete issue hook
One final API integration, the delete API takes the id
and also returns an object with isMutating
field and trigger
method:
export const useDeleteIssue = () => {
const { setClose } = useIssueModalStore();
const remove = (url: string, { arg }: { arg: { id: string } }) => fetch(`${url}/${arg.id}`, {
method: "DELETE",
}).then(res => res.json())
const { mutate: revalidateIssuesList } = useSWRConfig();
const { isMutating, trigger } = useSWRMutation("/api/issue", remove, {
onSuccess() {
toast.success("Issue has been deleted.");
setClose();
revalidateIssuesList("/api/issues")
},
onError(err) {
if (err.message) {
toast.error(err.message);
} else {
toast.error("Failed to delete issue");
}
},
})
return { isMutating, trigger }
}
Issue modal
Create an issue modal in /frontend/components/issue-modal.tsx
.
The issue modal will have a form with title, description, status select, and label select input fields.
Using Zod
First, we need to install Zod to define the schema for the form and add rules with helper messages to validate the user input:
npm install zod
Next, define the form schema:
import { z } from "zod";
//...rest of the imports
const status = ["todo", "inprogress", "done", "backlog"] as const;
const label = ["bug", "feature", "documentation"] as const;
const formSchema = z.object({
title: z.string().min(3, {
message: "title must be at least 3 characters.",
}),
description: z.string().min(5, {
message: "title must be at least 5 characters.",
}),
status: z.enum(status, {
required_error: "You need to select a status.",
}),
label: z.enum(label, {
required_error: "You need to select a label.",
}),
author: z.string({ required_error: "Author is required" }),
});
In the above code, we have created a label and a status enum for the select input field option. It should be the same as the expected label and status field value of the IssueSchema
.
Using React Hook Form
Now, we need to install React Hook Form for field validation and field state management:
npm install react-hook-form
React Hook Form has a useForm
hook which is used to handle the onChange
event and validate the form on the fly. It needs a type
parameter which will be the form definition consisting of the details of each field the form is having. We can infer the type by using the formSchema
we have defined using Zod.
useForm
hook accepts two options a resolver
and defaultValues
. For the resolver, we need to integrate preferred schema validation. We will use Zod resolver for validation which will take the formSchema
as a parameter to apply the validation rules.
For the defaultValues
for the form, but for the author we will not create a form element rather we will provide the user id
which we can get from useUser
hook from Clerk because we only want the user who is creating the issue to be the author of.
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useUser } from "@clerk/clerk-react";
//...rest of the imports
function IssueCard() {
const { user } = useUser();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
status: "todo",
label: "bug",
author: user?.id,
},
});
//...rest of the code
}
Conditional mutation and rendering for creation and update
So far we have added all the required hooks and components that can be used to create and update an issue. Now, we need to render the modal differently for the update than how it is for the creation.
For the update, we need the issue id
from useIssueStore
hook and fetch the issue first using useGetIssue
once we have retrieved the issue successfully we can update the state of the form using useEffect
hook and only allow the author to update their own created issues:
After that, we will define the onSubmit
handler function to conditionally create an issue using useCreateIssue
hook or update an issue using useEditIssue
hook:
import { useIssueStore } from "@/lib/store";
import { useCreateIssue, useEditIssue, useGetIssue } from "@/lib/hooks";
//...rest of the imports
function IssueCard() {
const { edit_issue_id } = useIssueStore();
const { data: issue } = useGetIssue(edit_issue_id);
const { isMutating: createMutating, trigger: createTrigger } =
useCreateIssue();
const { isMutating: editMutating, trigger: editTrigger } = useEditIssue();
function onSubmit(values: z.infer<typeof formSchema>) {
edit_issue_id === ""
? createTrigger(values)
: editTrigger({ id: edit_issue_id, ...values });
}
//Check for the author is editing or not
const noAuth = edit_issue_id !== "" && user?.id !== issue?.author;
}
Finally, your issue modal will look like this.
Issues table
Create a new directory inside the components directory issues-table
, inside this directory create two files index.tsx
, and row.tsx
.
Edit the row.tsx
file
We will define Row
component which will take props
having the type set to IssueSchema
.
Each row will have 6 cells for the author, title, description (truncated), status, label, and last cell for edit and delete buttons.
The edit button will set the issue id
to be edited using setEditIssueId
from useIssueStore
hook and open up the modal. The delete button will trigger the delete API in the useDeleteIssue
hook.
Both of these buttons is conditionally rendered based on the condition that if the signed-in user is an author of the issue then they can mutate the issue or else they can just view the issue.
Please find the complete code here.
Edit the index.tsx
file of the issue-table
component
Here you can find the complete code for the issue table**.
Update App.tsx
file
Now we can import our IssuesTable
component and render it:
import { SignIn, SignedIn, SignedOut, UserButton } from "@clerk/clerk-react";
import IssuesTable from "@/components/issue-table";
function App() {
return (
<main>
<SignedOut>
<SignIn />
</SignedOut>
<SignedIn>
<div className="p-20 flex flex-col items-center space-y-7">
<div className="fixed top-6 right-6">
<UserButton afterSignOutUrl="/" />
</div>
<IssuesTable />
</div>
</SignedIn>
</main>
);
}
export default App;
Update main.tsx
file
Add the Toaster
component from sonner
and IssueModal
:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { ClerkProvider } from "@clerk/clerk-react";
import { Toaster } from "@/components/ui/sonner";
import IssueModal from "@/components/issue-modal";
// Import your publishable key
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
if (!PUBLISHABLE_KEY) {
throw new Error("Missing Publishable Key");
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ClerkProvider publishableKey={PUBLISHABLE_KEY}>
<App />
<Toaster />
<IssueModal />
</ClerkProvider>
</React.StrictMode>
);
Run the application
First, build the frontend application using this command:
npm run build
This will generate an optimized build in the /frontend/dist
directory.
Finally run the backend using:
cargo shuttle run
Open the highlighted URL in the browser window:
Deployment
Now you can deploy your app on the Shuttle platform by running cargo shuttle start
and cargo shuttle deploy
(with the --allow-dirty
flag if you have uncommitted changes).
One important note is that the deployment using the development keys from Clerk will work just fine, but if you change to use the Production Instance of Clerk then you will need to update the keys with Live keys. Now since you have moved to production you will also need to set up the domain or subdomain of your application in Clerk and add the CNAME records that you will get from the Clerk dashboard to your DNS record for managing sessions, loading portal using your domain, sending verification emails, and using custom callback URL. Since this process can be different based on your domain provider and management system, I will leave a link to the Clerks documentation on Deploy to production.
Closing in
Thanks for reading!! That's a lot but I hope you have managed to deploy your application and had a good learning experience.
Top comments (0)