Demystifying ACID Transactions with Prisma: A Full-Stack Demonstration from Scratch
Table of contents
- The Issue and Purpose:
- What Is ACID?:
- Modeling our Application:
- Creating the central connection:
- Prisma ORM:
- Setting up the mock data:
- Explanation:
- Setting up the Server Structure.
- Explanation:
- CreateUserCart controller:
- Routes and Server:
- Explanation:
- Creating our React app through Vite:
- Modeling the frontend:
- Home.jsx component:
- Explanation:
- Single.jsx component:
- Explanation:
- Explanation:
- App.jsx
- Explanation:
- Conclusion:
In 2023, a popular online retail company, Tastytrove restaurant, boasted of its efficient inventory management system, which was crucial for handling thousands of carts and orders placed by its customers. The company’s system was designed to update stock levels of each seller's menu in real-time as customers placed orders, ensuring that items marked as "in stock" were in real-time factually available.
The Issue and Purpose:
One weekend, Tastytrove restaurant ran a major sale on "DRINK & COCKTAIL", drawing in a massive influx of customers. Unknowingly, due to a bug in the database transaction handling, the system experienced data inconsistency in the process of transactions. Specifically, the ACID properties (Atomicity, Consistency, Isolation, Durability) were not properly enforced during the high load.
You will not note that when proper records were not being computed on the database of Tastytrove restaurant collections properly, The sellers were not aware of which products were out of stock, and also which of their products had more engagement in sales.
What Is ACID?:
From the narrated sad event that took place in Tastytrove restaurant, Different outcomes were expected to be performed:
We expect the stock of each product to be well-computed
We also expect that the order and cart of each user to be carried out successfully
We can also see the need to make sure that both the stock of each product and the order of each user should not be left out in the computation process.
Since we are aware that not all systems are perfect, we can also conclude that one of these outcomes can fail and the other can pass irrespective, of whether the other experiences a failure.
A database transaction refers to a sequence of read/write operations that are guaranteed to either succeed or fail as a whole.
ACID is a Prisma query acronym that represents ATOMICITY, CONSISTENCY, ISOLATION, AND DURABILITY. I will explain each of them:
ATOMICITY: This helps to ensure that all of our transactions or operations pass. If one of them fails then the other fails. It creates a kind of chain to each action we are trying to perform. Either we intend to write to the database or get data from the database.
CONSISTENCY: A concept that ensures that the initial database does not have any disparity to the final database once the read or write operations have been performed. So if a transaction fails, the Prisma transactions query method looks for any disparity and returns the database to its initial state. If it passes, then it updates the database to the write operations performed. This is the main idea behind the word consistency.
ISOLATION: So Prisma looks to the fact that each operation should run the same way when alone as to when they are running both running at the same time. So a MENU count should update at the same time as when running at the same time creating a CART and updating the MENU
DURABILITY: When a transaction passes, the Prisma transactions query method ensures that it persists the data in the database to the write operations performed.
The major role of the transaction in this picture is to make sure that all parts of a requested action are performed; not a single one of the listed actions should be left out. If any fails then the rest of the actions will not be carried out.
So concerning what happened at Tastytrove restaurant, Transaction aims to view both the product's STOCK computation and the cart placed by the user as a single operation. If the stock computation fails, the user order action will be withdrawn in such a way that the user never bought the product.
Modeling our Application:
For us to fully illustrate the concept of ACID using Prisma ORM, So we have to create a folder called tastytrove; Inside this folder, it will contain a folder called backend and a folder called frontend respectively, and a central package.json that will be used in running the frontend and the backend concurrently. This backend folder will contain our server, controllers, Prisma schema, and routes. At the same time, the frontend folder will contain our React app.
Creating the central connection:
In the Tastytrove folder, we will run npm init --y
This will create a script for us which we will modify to suit our desire**.**
{
"scripts": {
"dev": "concurrently \"cd ./backend && npm run dev\" \"cd ./frontend && npm run dev\"",
"preview": "vite preview"
},
"devDependencies": {
"concurrently": "^8.2.0"
},
"dependencies": {
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.4.11"
}
}
Explanation:
So in the script above, we installed concurrently which allows us to run the server and the client at the same time. So when we run npm run dev it will run this script
"concurrently \"cd ./backend && npm run dev\" \"cd ./frontend && npm run dev\"",
It is just saying to go to the backend end folder first and run npm run dev
. Once you are done, go to the frontend also and run npm run dev
. So let us proceed in setting up our frontend application.
Prisma ORM:
In this section, I will show how we can amend the Tastytrove restaurant inventory system so it can mitigate any disagreement in the STOCK count of the food and the order placed.
For us to achieve this we have to set up Primsa. First, we will cd to the backend folder. In this backend folder, we will run npm init --y
. This will create a package.json file for us. We will further proceed to install our pre-requisite packages.
npm i dotenv express @prisma/client express-async-handler mongoose nodemon
Explanation:
So the essence of installing these packages is listed below:
dotenv: This provides us access to our .env variables
express: Needed in setting up the express server ( Or our custom server)
express-async-handler: This allows us to catch errors and modify how we send the error.
mongoose: A package that is useful in setting a MongoDB entry point
@prisma/client: This package allows us to be able to interact with Prisma on our server
nodemon: This package helps us to run the index.js of the server continuously.
Now that we have set the necessary packages, one package is still left out, Which is the Prisma ORM. Without this package we cannot interact with our model so let us install it:
npm install prisma --save-dev
After running the installation, we will have to invoke Prisma into our application:
npx prisma
Lastly, after this, we have to set up the Prisma schema
npx prisma init
So this initializes a Prisma schema template for us, we also need to set up our schema according to our taste.
In the index.js file of the Prisma folder, we set up a prismaClient which will help us import prisma in our node js application and also interact with our schema seamlessly.
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma
So after the invocation, we have to set up the Prisma schema. This schema or model will be three namely, the MENU model, Cart Model, and Payment Model.
We will have to clean up our prisma and make use of this one below we have to get our MongoDb keys, but before getting the keys let me explain the intricacies behind this.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("DATABASE_URL")
}
enum CartStatus {
PENDING
CONFIRMED
CANCELLED
}
model Menu {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
description String
price Int
category String?
image String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
availabilityCount Int
servedCount Int? @default(0)
cart Cart[]
}
model Cart {
id String @id @default(auto()) @map("_id") @db.ObjectId
totalPrice Int?
totalCount Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
menuid String @db.ObjectId
status CartStatus @default(PENDING)
menu Menu? @relation(fields: [menuid], references: [id], onDelete: Cascade)
}
Explanation:
We established a one-to-many relationship between the Menu schema to the Cart Schema. A one-to-many relationship is just a relationship where one record, in this case Menu, has a connection or a relationship to multiple records, a series of Cart for our case of study. It does so by using a specific ID that is unique to the Menu record to connect to multiple Cart records.
Menu Model: This represents a menu item in our database. This item consists of fields like
id
,title
,description
,price
,category
,image
,createdAt
,updatedAt
,availabilityCount
, andservedCount
. As you can see it establishes a relationship between, the Cart model through a means of thecart
field, which is an array (Cart[]
). This indicates that oneMenu
item can be associated with multipleCart
entries.Cart Model: It denotes a shopping item or an item in our Cart. Fields such as
id
,totalPrice
,totalCount
,createdAt
,updatedAt
,menuid
,status
, and a relation to the Menu model. Themenuid
field is a distinct objectId key that is peculiar to the Menu Model.
We have to establish a MongoDB URL connection, so it can store our data in its atlas database.
Setting up your MongoDB connection URL is simple, we just have to follow these steps:
Sign Up for MongoDB Atlas: If you don't have an account, you can go to MongoDB Atlas.
Log In to MongoDB Atlas: Log in to your MongoDB Atlas account.
Create a cluster: Click "Create Cluster" to start the cluster creation process. This may take a few minutes.
Go ahead in Configuring Your Cluster
Database Access:
Navigate to the "Database Access" tab.
Click "Add New Database User".
Create a username and password for your database user. Make sure to note these down, as you will need them later.
Set the user permissions to "Read and write to any database" (or adjust based on your needs).
Click "Add User".
Network Access:
Navigate to the "Network Access" tab.
Click "Add IP Address".
You can either allow access from anywhere (not recommended for production) by clicking "Allow Access from Anywhere" or specify the IP addresses that can access the database.
Click "Confirm".
Step 4: Obtain the Connection URL
Connect to Your Cluster:
Go to the "Clusters" tab.
Click "Connect" next to your cluster.
Select "Connect Your Application".
Copy the Connection String:
Choose your driver and version (e.g., Node.js and the latest version).
Copy the connection string provided. It will look something like this:
codemongodb+srv://<username>:<password>@cluster0.mongodb.net/myFirstDatabase?retryWrites=true&w=majority
Replace
<username>
and<password>
:Replace
<username>
and<password>
with the database user credentials you created earlier.Optionally, replace
myMongoFirstDatabase
with the name of the database you want to connect to.In our .env file, we will then add this:
DATABASE_URL= mongodb+srv://dbUser:dbPassword@cluster0.mongodb.net/myMongoFirstDatabase?retryWrites=true&w=majority
Setting up the mock data:
Now that we have set up our MongoDB URL, we have to mock up a list of menus. To do that we have to create a folder called data. Within this data folder, we will create a file called menu.js.
export const menudata = [
// foods
{
image:
"https://avada.website/restaurant/wp-content/uploads/sites/112/2020/01/menu262x-600x687.jpg",
title: "Twice Cooked Pork",
availabilityCount:5,
price: 21.0,
description:
"Tmi nulla in consequat, ut. Metus, nullam scelerisque netus viverra dui pretium pulvinar. Commodo morbi amet.",
category: "Main Course",
},
{
image:
"https://avada.website/restaurant/wp-content/uploads/sites/112/2020/01/menu272x-600x687.jpg",
title: "Kung Pao Chicken",
availabilityCount:15,
price: 21.0,
description:
"Tmi nulla in consequat, ut. Metus, nullam scelerisque netus viverra dui pretium pulvinar. Commodo morbi amet.",
category: "Main Course",
},
{
image:
"https://avada.website/restaurant/wp-content/uploads/sites/112/2020/01/menu282x-600x687.jpg",
title: "California Rolls",
availabilityCount:10,
price: 40.0,
description:
"Tmi nulla in consequat, ut. Metus, nullam scelerisque netus viverra dui pretium pulvinar. Commodo morbi amet.",
category: "Main Course",
},
{
image:
"https://avada.website/restaurant/wp-content/uploads/sites/112/2020/01/menu372x-600x687.jpg",
title: "Braised Abalone",
availabilityCount:5,
price: 52.0,
description:
"Tmi nulla in consequat, ut. Metus, nullam scelerisque netus viverra dui pretium pulvinar. Commodo morbi amet.",
category: "Main Course",
},
]
After setting up our fake menu data, we have to proceed with creating our seeder file. This seeder file will be responsible for inserting the menu data into our MongoDB atlas database. So in the backend folder, we will create a file called seeder.js.
import mongoose from "mongoose";
import dotenv from "dotenv";
import { PrismaClient } from "@prisma/client";
import { menudata } from "./data/menu.js";
dotenv.config();
const prisma = new PrismaClient();
const mongoUrl = process.env.DATABASE_URL;
if (!mongoUrl) {
throw new Error("MongoDB connection string is not defined.");
}
mongoose.connect(mongoUrl, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
mongoose.connection.on("error", (error) =>
console.error("MongoDB connection error:", error)
);
const importData = async () => {
try {
// Use Prisma to insert data
await prisma.menu.createMany({
data: menudata,
});
console.log("Data Imported!");
process.exit();
} catch (error) {
console.error("Error importing data:", error);
process.exit(1);
}
};
importData();
Explanation:
So in the first 12 lines, we imported the necessary tools, initialized prismaClient, and established a MongoDB connection.
import mongoose from "mongoose";
import dotenv from "dotenv";
import { PrismaClient } from "@prisma/client";
import { menudata } from "./data/menu.js";
dotenv.config();
const prisma = new PrismaClient();
const mongoUrl = process.env.DATABASE_URL;
if (!mongoUrl) {
throw new Error("MongoDB connection string is not defined.");
}
mongoose.connect(mongoUrl, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
mongoose.connection.on("error", (error) =>
console.error("MongoDB connection error:", error)
);
Next in line, we defined an asynchronous function to insert the menu data we created into the MongoDB database. It makes use of the Prisma write method called insertMany. A try-catch block is established to see that we capture the error that will occur during this many insertions and the message would be displayed.
const importData = async () => {
try {
// Use Prisma to insert data
await prisma.menu.createMany({
data: menudata,
});
console.log("Data Imported!");
process.exit();
} catch (error) {
console.error("Error importing data:", error);
process.exit(1);
}
};
Next, we have to modify our package.json script, so we can be able to write the menu data into our database.
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"server": "node ./index.js",
"data:import": "node seeder",
"dev": "nodemon ./index.js",
"postinstall": "prisma generate",
"build": "prisma generate && npm run build"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^5.5.2",
"dotenv": "^8.2.0",
"express": "^4.17.3",
"express-async-handler": "^1.2.0",
"moment": "^2.29.4",
"mongoose": "^7.4.3",
"nodemon": "^3.0.1"
},
"devDependencies": {
"prisma": "^5.5.2"
}
}
So when we run this script npm run data:import,
we will proceed to the seeder file to insert the menu data. Please note, that this should be run only once so that there will not be multiple data, it is not necessary.
"data:import": "node seeder",
Setting up the Server Structure.
So we have to set up folders called controllers, routes, and a file called index.js. Let us proceed in setting up the controllers folder. In the controller folder, we will have 2 files called menuControllers.js and cartController.js. These files will have a function that will perform read and write operations to our database.
menuControllers.js
import asyncHandler from "express-async-handler";
import prisma from "../prisma/index.js";
// Get all the Menu we inserted to the database
const GetAllMenu = asyncHandler(async (req, res) => {
const Menus = await prisma.menu.findMany();
return res.json(Menus);
});
// Get a single Menu
const GetSingleMenu = asyncHandler(async (req, res) => {
// get the id from the request params
const id = req.params.id;
const Menu = await prisma.menu.findUnique({
where: {
id: id,
},
});
// if no menu send an error
if (!Menu) {
res.status(404);
throw new Error("The Menu does not exist");
}
return res.json(Menu);
});
export {
GetAllMenu,
GetSingleMenu,
};
Explanation:
So in the GetAllMenu controller, we tend to get all the menus by performing a read method using the Prisma query, findMany
. This query method will provide us with all of our menus in the MongoDB database
const GetAllMenu = asyncHandler(async (req, res) => {
const Menus = await prisma.menu.findMany();
return res.json(Menus);
});
The next controller tends to perform also a query by getting a specific menu using the menu's ID
// Get a single Menu
const GetSingleMenu = asyncHandler(async (req, res) => {
// get the id from the request params
const id = req.params.id;
const Menu = await prisma.menu.findUnique({
where: {
id: id,
},
});
// if no menu send an error
if (!Menu) {
res.status(404);
throw new Error("The Menu does not exist");
}
return res.json(Menu);
});
Let us proceed to set up our cartController.js file:
cartControllers.js:
import asyncHandler from "express-async-handler";
import { parse, formatISO } from "date-fns";
import prisma from "../prisma/index.js";
// Get a single Menu
const CreateUserCart = asyncHandler(async (req, res) => {
const id = req.params.id;
const session = await prisma.$transaction(async (prisma) => {
// check if the menu exists in the cart
// if it exists update the cart
// if it does not exist go unto create a new cart for the user and update the menu data
// find the menu in the cart to check if it exists
const existingCartItem = await prisma.cart.findFirst({
where: { menuid: id },
});
if (existingCartItem) {
const { totalCount, totalPrice } = req.body;
const newCart = await prisma.cart.update({
where: {
id: existingCartItem?.id,
},
data: {
totalPrice: totalPrice,
menuid: id,
status: "PENDING",
totalCount: totalCount,
},
});
// console.log(menu);
return { newCart };
} else {
const { totalCount, totalPrice } = req.body;
const menu = await prisma.menu.findUnique({
where: { id: id },
});
// check if the menu exists
if (!menu) {
res.status(404);
throw new Error("The menu does not exist");
}
// check if the menu avalability count is less than the request body count Stock
if (menu.availabilityCount < totalCount) {
res.status(400);
throw new Error("Insufficient availability");
}
await prisma.menu.update({
where: { id },
data: {
availabilityCount: menu.availabilityCount - totalCount,
servedCount: totalCount,
},
});
const newCart = await prisma.cart.create({
data: {
totalPrice: totalPrice,
menuid: id,
status: "PENDING",
totalCount: totalCount,
},
});
// console.log(menu);
return { newCart };
}
});
return res.json(session);
});
const GetAllCart = asyncHandler(async (req, res) => {
const availableRooms = await prisma.cart.findMany({
include: {
menu: true,
},
orderBy: {
createdAt: "desc",
},
});
return res.json(availableRooms);
});
export {
GetAllCart,
CreateUserCart,
};
So this code snippet looks scary, Lol, Let us explain it together and see how the Prisma Transaction query works.
CreateUserCart controller:
We will focus first on the CreateUserCart controller because this is where we will see the essence of the Prisma transaction query. In this controller, we are performing at least 2 read operations and 3 write operations. This Prisma Transaction query method ensures that the 2 read and 3 write operations must be successful, If one fails then the rest actions will not be performed.
In the first read operation, we look if there is an existing cart Item. The main aim is so that we do not produce duplicate cart items. so when a cart item exists all it does it just to update the Cart totalPrice, quantity, and totalCount using the Prisma.update method then returns the updated value. This (Prisma.update
) also is the first write method.
const existingCartItem = await prisma.cart.findFirst({
where: { menuid: id },
});
if (existingCartItem) {
const { totalCount, totalPrice } = req.body;
const newCart = await prisma.cart.update({
where: {
id: existingCartItem?.id,
},
data: {
totalPrice: totalPrice,
menuid: id,
status: "PENDING",
totalCount: totalCount,
},
});
// console.log(menu);
return { newCart };
}
Next when such a cartItem does not exist, we proceed further to creating the cart and performing other operations.
So in the next phase, we perform another read operation to see if the menu exists, and also if the menu's availability count is less than the requested quantity from the user. If all these are true an error will then be returned.
const { totalCount, totalPrice } = req.body;
const menu = await prisma.menu.findUnique({
where: { id: id },
});
// check if the menu exists
if (!menu) {
res.status(404);
throw new Error("The menu does not exist");
}
// check if the menu avalability count is less than the request body count Stock
if (menu.availabilityCount < totalCount) {
res.status(400);
throw new Error("Insufficient availability");
}
When the above is not satisfied, we then proceed to reduce the menu's availability by subtracting the requested menu availability from the requested quantity from the user and also increasing the servedCount for the menu. When we have updated the menu, we can then create a new cart for the user.
await prisma.menu.update({
where: { id },
data: {
availabilityCount: menu.availabilityCount - totalCount,
servedCount: totalCount,
},
});
const newCart = await prisma.cart.create({
data: {
totalPrice: totalPrice,
userid: req.user.userId,
menuid: id,
status: "PENDING",
totalCount: totalCount,
},
});
// console.log(menu);
return { newCart };
Overall, we can see how the Prisma transaction query method ($transaction) works. It ensures that all of our read-and-write requests or actions are performed. If one fails all fail. If all passes then all passes.
So after achieving this, let us proceed to setting up our routes and server.
Routes and Server:
In the backend folder, we will create a folder called routes which will contain the cartRoute.js and menuRoute.js files. When you are done setting up these files, let us proceed to the cartRoute.js file. In the cartRoute.js file, we have to import our controllers:
import express from "express";
const router = express.Router();
import {
CreateUserCart,
GetAllCart,
} from "../controllers/cartControllers.js";
router.route("/user").get(GetAllCart);
router
.route("/:id")
.post(CreateUserCart)
export default router;
We finally set a route that will listen to POST, and GET cart requests. We also have to create our menuRoute.js file.
import express from "express";
const router = express.Router(
import {
GetAllMenu,
GetSingleMenu,
} from "../controllers/menuControllers.js";
router
.route("/")
.get(GetAllMenu)
router
.route("/:id")
.get(GetSingleMenu)
export default router;
In this section, we also set up routes that will listen to POST, and GET cart requests. We then have to export the routes to the index.js file. So let's proceed to setting up our express server.
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app = express();
// setting up CORS
app.use(
cors({
origin: process.env.WEB_ORIGIN,
methods: ["POST", "GET", "DELETE", "PUT"],
credentials: true,
})
);
// middlewares
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
import cartRoute from "./routes/cartRoutes.js";
import menuRoute from "./routes/menuRoutes.js";
//end point
app.use("/api/v1/cart", cartRoute);
app.use("/api/v1/menu", menuRoute );
// server listening port
server.listen(4000, () => {
console.log("server is listening on port 4000");
});
Explanation:
We first imported the express package that will be needed in creating our express server, then set up our dotenv config.
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
Next in line we then created our express server:
const app = express();
So we have to prevent CORS errors when we are making our API request which can be as a PUT, POST, DELETE, AND GET REQUEST from the frontend:
// CORS errors prevention.
app.use(
cors({
origin: process.env.WEB_ORIGIN,
methods: ["POST", "GET", "DELETE", "PUT"],
credentials: true,
})
);
Next, we set a middleware that will handle our request body parameter in JSON format
// middlewares
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
Finally, we then set up our main endpoint, by importing the route. And lastly, we set our server to be listening to port 4000
import cartRoute from "./routes/cartRoutes.js";
import menuRoute from "./routes/menuRoutes.js";
//end point
app.use("/api/v1/cart", cartRoute);
app.use("/api/v1/menu", menuRoute );
// server listening port
server.listen(4000, () => {
console.log("server is listening on port 4000");
});
Creating our React app through Vite:
We need a client section to test our REST API, For faster development of the front end, we will use Vite to install Reactjs rather than the contemporary create-react app. So let's proceed:
cd tastytrove
npm create vite@latest frontend -- --template react
cd tastytrove/frontend
So in the script above we navigated to the frontend folder in our tastytrove folder and created a vite application. The next step is creating and installing Install tailwindcss
and its peer dependencies, then generating your tailwind.config.js
and postcss.config.js
files. Still, in the frontend folder, we type the following commands:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
We have to proceed further to configure our tailwind.config.js file located in the frontend folder
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
The final lap of the tailwind configuration is adding these directives to our CSS file
//index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
We have to install the necessary React packages for our frontend application
npm i axios moment react-router-dom react-hot-toast react-icons --legacy-peer-deps
Now that we are done installing the pre-requisite packages, we will proceed to clear the App.js content.
Modeling the frontend:
In the frontend src folder, we will create the following folders:
components folder: This folder will house the individual building blocks of our pages. They are mostly Reactjs functional components.
screens folder: This folder houses our screens, which contain those component files. They are the pages which display our individual content
So when we are done with setting up the structure, we will proceed in developing the screens and their various components.
In the components folder, we will create a file called Home.jsx. This file will contain the component responsible for displaying our menu.
Home.jsx component:
The intent of creating this component is to make it take a UI that will render the menu we fetched from the server.
import { Link } from "react-router-dom";
const Home = () => {
const [loading, setLoading] = useState(false);
const [menu, setMenu] = useState([]);
const GetAllMenu = async () => {
try {
// set the loading state to true
setLoading(true);
const { data } = await axios.get(
`http://localhost:4000/api/v1/menu`
);
setLoading(false);
setMenu(data);
} catch (error) {
setLoading(false);
toast.error(
error.response && error.response.data.message
? error.response.data.message
: error.message
);
}
};
useEffect(() => {
GetAllMenu();
}, []);
if (loading) {
return (
<div className="w-full h-screen flex items-center justify-center">
<h2 className="text-4xl text-center font-bold">
Getting all Menu ....
</h2>
</div>
);
}
return (
<>
<div className="w-full h-screen flex flex-col items-center justify-center">
<h2 className="text-5xl text-center font-bold">Menu</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-20">
{menu?.map((data) => {
return (
<Link
to={`/restaurant/${data?.id}`}
className="flex w-full group flex-col gap-8"
>
<div className="w-full h-52">
<img
src={data?.image}
alt=""
className="w-full h-full object-cover"
/>
</div>
<div className="flex w-full flex-col gap-4">
<div className="flex items-center justify-between w-full">
<h4 className="text-4xl group-hover:text-[var(--primary)] family3">
{data?.title}
</h4>
<h4 className="text-xl font-normal family4">
${data?.price}
</h4>
</div>
<p className="text-lg leading-[1.5] family4">
{data?.description}
</p>
</div>
</Link>
);
})}
</div>
</div>
</>
);
};
Explanation:
So in the above code, we created a functional component (Home. js), which is a function that returns a React component.
It sends a GET request to the menu endpoint (/api/v1/menu) to get all the Menus using Axios.
Once the menu has been gotten, it then displays the menu using a grid container. Next, we have to create a single.js component. This (single) component will display a single menu.
Single.jsx component:
The intent of creating this component is to make it take a UI that will render the menu we fetched from the server and also will perform a post request to the cart endpoint.
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BiMinus, BiPlus } from "react-icons/bi";
import axios from "axios";
import toast from "react-hot-toast";
const Single = () => {
const [loading, setLoading] = useState(false);
const [cartsuccess, setCartSuccess] = useState(false);
const [menu, setMenu] = useState(null);
const { id } = useParams();
const [count, setCount] = useState(1);
// get a single menu
const GetSingleMenu = async () => {
try {
// set the loading state to true
setLoading(true);
const { data } = await axios.get(
`${import.meta.env.VITE_API_BASE_URLS}/menu/${id}`
);
setLoading(false);
setMenu(data);
} catch (error) {
setLoading(false);
toast.error(
error.response && error.response.data.message
? error.response.data.message
: error.message
);
}
};
useEffect(() => {
// get a single menu and clear the cart success state to false if true
GetSingleMenu();
setCartSuccess(false);
}, []);
if (loading) {
return (
<div className="w-full h-screen flex items-center justify-center">
<h2 className="text-4xl text-center font-bold">
Getting the Menu ....
</h2>
</div>
);
}
// initialize the navigate router
const navigate = useNavigate();
// get the total price
const totalPrice = menu?.price * count;
const menudata = {
totalCount: count,
totalPrice: totalPrice,
};
const handleCartBooking = async () => {
try {
// set the cart creation loading state to false
setLoading(true);
const { data } = await axios.post(
`${import.meta.env.VITE_API_BASE_URLS}/cart/${menu?.id}`,
menudata
);
// set the cart creation loading state to false
setLoading(false);
// set cart success to true so it will navigate after 3s
setCartSuccess(true);
setMenu(data);
} catch (error) {
setLoading(false);
setCartSuccess(false);
toast.error(
error.response && error.response.data.message
? error.response.data.message
: error.message
);
}
};
useEffect(() => {
if (cartsuccess) {
const interval = setTimeout(() => {
navigate(`/restaurant/cart`);
}, 4000);
return () => clearTimeout(interval);
}
}, [cartsuccess]);
return (
<>
<div className="w-full h-screen flex items-center justify-center">
<div className="w-[90%] mx-auto">
<div className="w-full items-start grid md:grid-cols-2">
<div className="w-full h-full">
<img
loading="lazy"
src={menu?.image}
className="w-full z-10 h-full object-cover"
/>
<div className="absolute z-20 bottom-10 w-full flex items-center px-12">
<p className="text-3xl text-white family4">${menu?.price}</p>
</div>
</div>
<div className="py-24 bg-[#000]">
<div className="flex w-[80%] md:w-[65%] mx-auto flex-col gap-8 auto">
<div className="family3 text-5xl md:text-6xl text-white">
{menu?.title}
</div>
<h4 className="text-xl leading-[1.4] family2 text-white">
{menu?.description}
</h4>
<p className="text-2xl text-white family4">${menu?.price}</p>
<div className="w-full gap-8 grid md:grid-cols-2 lg:grid-cols-2 text-start">
<button
onClick={handleCartBooking}
className="h-[55px] w-[200px] text-sm"
>
ADD TO CART
</button>
<span className="grid h-[50px] md:h-full grid-cols-3 border border-[rgba(255,255,255,.6)] items-center justify-between">
<button
onClick={() => setCount(count - 1)}
disabled={count <= 1}
className=" h-full w-full flex items-center justify-center border-r
border-[rgba(255,255,255,.6)] text-base text-white cursor-pointer"
>
<BiMinus />
</button>
<span
className=" h-full family1 w-full flex items-center justify-center
border-r border-[rgba(255,255,255,.6)] text-base lg:text-xl text-white cursor-pointer"
>
{count}
</span>
<button
onClick={() => setCount(count + 1)}
disabled={count === menu?.availabilityCount}
className=" h-full w-full text-base text-white cursor-pointer flex items-center justify-center "
>
<BiPlus />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
};
Explanation:
In the first 6 lines, we imported the required packages that will facilitate the rendering of the single items. Axios is used in performing HTTP requests, toast from react-hot-toast will help us in displaying messages sent from the server. useNavigate helps us navigate from one page to another seamlessly.
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BiMinus, BiPlus } from "react-icons/bi";
import axios from "axios";
import toast from "react-hot-toast";
In the next couple of lines, we get the menu ID from the request parameter using the useParams hook, next, we set up states that will track the progress and completion of our GET request in getting the single menu from the server. We created an asynchronous function that will render once the components mount. It sends a GET request to the single menu endpoint. We update the necessary states that track the progress and completion of the requests. When the GET request is in progress we display a UI that shows us a message that the request has not been completed but is in progress.
const [loading, setLoading] = useState(false);
const [cartsuccess, setCartSuccess] = useState(false);
const [menu, setMenu] = useState(null);
const { id } = useParams();
const [count, setCount] = useState(1);
// get a single menu
const GetSingleMenu = async () => {
try {
// set the loading state to true
setLoading(true);
const { data } = await axios.get(
`${import.meta.env.VITE_API_BASE_URLS}/menu/${id}`
);
setLoading(false);
setMenu(data);
} catch (error) {
setLoading(false);
toast.error(
error.response && error.response.data.message
? error.response.data.message
: error.message
);
}
};
useEffect(() => {
// get a single menu and clear the cart success state to false if true
GetSingleMenu();
setCartSuccess(false);
}, []);
if (loading) {
return (
<div className="w-full h-screen flex items-center justify-center">
<h2 className="text-4xl text-center font-bold">
Getting the Menu ....
</h2>
</div>
);
}
After getting the menu, we have to perform a POST request that will create a cart for us. So we created an asynchronous function called handleCartBooking. This function aims to create a cart for the user and also during this asynchronous action, We have to track the progress and completion of the request. When the action has been completed we set a timeout that will take us the Cart page. The total price was being calculated as the product of the count item to the menu price.
// initialize the navigate router
const navigate = useNavigate();
// get the total price
const totalPrice = menu?.price * count;
// set the cart data
const cartdata = {
totalCount: count,
totalPrice: totalPrice,
};
const handleReservationBooking = async () => {
try {
// set the cart creation loading state to false
setCartLoading(true);
const { data } = await axios.post(
`${import.meta.env.VITE_API_BASE_URLS}/cart/${menu?.id}`,
cartdata
);
// set the cart creation loading state to false
setCartLoading(false);
// set cart success to true so it will navigate after 3s
setCartSuccess(true);
} catch (error) {
setCartLoading(false);
setCartSuccess(false);
toast.error(
error.response && error.response.data.message
? error.response.data.message
: error.message
);
}
};
useEffect(() => {
if (cartsuccess) {
const interval = setTimeout(() => {
navigate(`/restaurant/cart`);
}, 4000);
return () => clearTimeout(interval);
}
}, [cartsuccess]);
So lastly, we returned the building element of the single component. In the decrement button, we disabled it when the count <1, and also in the increment button we disable the button when the count is equal to the menu's availability.
<div className="w-full gap-8 grid md:grid-cols-2 lg:grid-cols-2 text-start">
<button
onClick={handleReservationBooking}
className="h-[55px] w-[200px] text-sm"
>
ADD TO CART
</button>
<span className="grid h-[50px] md:h-full grid-cols-3 border border-[rgba(255,255,255,.6)] items-center justify-between">
<button
onClick={() => setCount(count - 1)}
disabled={count <= 1}
className=" h-full w-full flex items-center justify-center border-r
border-[rgba(255,255,255,.6)] text-base text-white cursor-pointer"
>
<BiMinus />
</button>
<span
className=" h-full family1 w-full flex items-center justify-center
border-r border-[rgba(255,255,255,.6)] text-base lg:text-xl text-white cursor-pointer"
>
{count}
</span>
<button
onClick={() => setCount(count + 1)}
disabled={count === menu?.availabilityCount}
className=" h-full w-full text-base text-white cursor-pointer flex items-center justify-center "
>
<BiPlus />
</button>
</span>
</div>
Now we have to get our cart items and display them in a component called the cart components. This component will later be imported to the Cart page.
Cart Component (cart.jsx):
So we have to create a folder called cart, this folder is suited to the component folder. Within this cart folder, we will create an index.jsx file, a Card.jsx file ( this will be used to render our table for us).
So let us proceed in creating our index.js file.
import React, { useEffect, useState } from "react";
const Cart = () => {
return (
<div className={`w-full`}>
</div>
);
};
export default Cart
Now that we are done creating the cart index.js file, we create our Card component called (Card.jsx)
const Card = () => {
return (
<div className={`w-full`}>
</div>
);
};
export default Card
In the index.js file of the cart component folder, we will have to get the cart Items from the server. So we will send a GET request to fetch the cart items we have created.
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BiMinus, BiPlus } from "react-icons/bi";
import axios from "axios";
import toast from "react-hot-toast";
const Cart = () => {
const [cartloading, setCartLoading] = useState(false);
const [cart, setCart] = useState([]);
const GetAllCartItem = async () => {
try {
// set the loading state to true
setCartLoading(true);
const { data } = await axios.get(
`${import.meta.env.VITE_API_BASE_URLS}/cart`
);
setCartLoading(false);
setCart(data);
} catch (error) {
setCartLoading(false);
toast.error(
error.response && error.response.data.message
? error.response.data.message
: error.message
);
}
};
useEffect(() => {
GetAllCartItem();
}, []);
if (cartloading) {
return (
<div className="w-full h-screen flex items-center justify-center">
<h2 className="text-4xl text-center font-bold">
Getting your Cart Items ....
</h2>
</div>
);
}
return (
<div className={`w-full`}>
{cart?.length === 0 ? (
<div className="w-full flex items-center justify-center flex-col gap-2">
<h2 className="text-4xl md:text-5xl text-dark family3">
Cart is empty
</h2>
<Link to={""} className="p-3 border text-lg">
Browse Our Menu
</Link>
</div>
) : (
<>
<table className="border-collapse table-fixed">
<thead>
<tr className="p-4">
<th className="p-4 text-base text-start font-normal">
Product
</th>
<th className="p-4 text-base text-start font-normal">Price</th>
<th className="p-4 text-base text-start font-normal">
Quantity
</th>
<th className="p-4 text-base text-start font-normal">
Subtotal
</th>
<th className="p-4 text-base text-start font-normal"></th>
</tr>
</thead>
<tbody>
{cart?.map((x) => {
return <Card key={x.id} x={x} />;
})}
</tbody>
</table>
</>
)}
</div>
);
};
export default Cart
Explanation:
In the first 6 lines, we imported the required packages that will facilitate the rendering of the single items. Axios is used in performing HTTP requests, toast from react-hot-toast will help us in displaying messages sent from the server.
import React, { useEffect, useState } from "react";
import { BiMinus, BiPlus } from "react-icons/bi";
import axios from "axios";
import toast from "react-hot-toast";
Next, we have we get all the cart Items, next, we set up states that will track the progress and completion of our GET request in getting the cart items from the server. We created an asynchronous function that will render once the components mount. It sends a GET request to the all-cart endpoint. We update the necessary states that track the progress and completion of the requests. When the GET request is in progress we display a UI that shows us a message that the request has not been completed but is in progress.
const [cartloading, setCartLoading] = useState(false);
const [cart, setCart] = useState([]);
const GetAllCartItem = async () => {
try {
// set the loading state to true
setCartLoading(true);
const { data } = await axios.get(
`${import.meta.env.VITE_API_BASE_URLS}/cart`
);
setCartLoading(false);
setCart(data);
} catch (error) {
setCartLoading(false);
toast.error(
error.response && error.response.data.message
? error.response.data.message
: error.message
);
}
};
useEffect(() => {
GetAllCartItem();
}, []);
if (cartloading) {
return (
<div className="w-full h-screen flex items-center justify-center">
<h2 className="text-4xl text-center font-bold">
Getting your Cart Items ....
</h2>
</div>
);
}
Next, we render the building blocks of the React component. In the return HTML content, we check to see if the cart length is less than 0. If it is less than o we show a message that the user has no cart Items, but if it is greater than 0 we then display the cart content for the user.
When we display the content of the cart, we have to map the map through the cart array and then render the Cart Card.
<div className={`w-full`}>
{cart?.length === 0 ? (
<div className="w-full flex items-center justify-center flex-col gap-2">
<h2 className="text-4xl md:text-5xl text-dark family3">
Cart is empty
</h2>
<Link to={""} className="p-3 border text-lg">
Browse Our Menu
</Link>
</div>
) : (
<>
<table className="border-collapse table-fixed">
<thead>
<tr className="p-4">
<th className="p-4 text-base text-start font-normal">
Product
</th>
<th className="p-4 text-base text-start font-normal">Price</th>
<th className="p-4 text-base text-start font-normal">
Quantity
</th>
<th className="p-4 text-base text-start font-normal">
Subtotal
</th>
<th className="p-4 text-base text-start font-normal"></th>
</tr>
</thead>
<tbody>
{cart?.map((x) => {
return <Card key={x.id} x={x} />;
})}
</tbody>
</table>
</>
)}
</div>
Let us proceed further in modifying the content of the cart Card.
const Card = ({ cart }) => {
const [cartcount, setCartCount] = useState(0);
useEffect(() => {
setCartCount(cart?.totalCount);
}, [cart, setCartCount]);
return (
<tr key={cart?.id}>
<td className="py-3 px-4 text-base border-b">
<div className="flex items-center gap-4">
<div className="">
<img
src={cart?.menu?.image}
className="w-[100px] obejct-cover"
alt="images"
/>
</div>
{cart?.menu?.title}
</div>
</td>
{/* <td className="text-lg">{cart?.price}</td> */}
<td className="py-3 px-4 text-base border-b">${cart?.menu?.price}</td>
<td className="py-3 px-4 text-base border-b">
<div className="w-[120px] h-[45px] flex items-center justify-center border">
<button
className="h-full flex items-center justify-center border-r"
disabled={cartcount >= cart?.menu?.availabilityCount}
onClick={() => setCartCount(cartcount + 1)}
>
<BiPlus fontSize={"14px"} />
</button>
<span className="border-l text-lg h-full font-bold border-r">
{cartcount}
</span>
<button
className="h-full flex items-center justify-center border-l"
disabled={cartcount === 1}
onClick={() => setCartCount(cartcount - 1)}
>
<BiMinus fontSize={"14px"} />
</button>
</div>
</td>
<td className="py-3 px-4 text-base border-b">
${cart?.menu?.price * cartcount}
</td>
<td className="py-3 px-4 text-base border-b">
<div className="w-12 h-12 rounded-full hover:bg-[#eee] flex items-center justify-center cursor-pointer">
<RxCross1 />
</div>
</td>
</tr>
);
};
Explanation:
So we got the props that were passed through it when it was rendered in the index.js of the cart component folder. The following actions were carried out on the Cart Card component:
Rendering: Renders a table row for each cart item with its details:
Product Image and Title: Displays the product image and title.
Price: Displays the price of the item.
Quantity: Allows the user to increase or decrease the quantity using buttons.
Subtotal: Calculates and displays the subtotal for the item.
So we are done with the cart components. We have to create screens for the components. First, we have to create a folder in the src directory called screens, then we will proceed to create the Home Screen, Single Screen, and Cart Screen.
Home Screen (Home.jsx)
In this screen, we have to import the home components into the Home screen.
import HomeComponents from "../components/home";
const Home = () => {
return (
<div className={`w-full`}>
<HomeComponents />
</div>
);
};
export default Home
Next, we have to create our single screen.
Single Screen (Single.jsx)
So in the single screen, we have to import the single components into the Single screen.
import SingleComponents from "../components/single";
const Single = () => {
return (
<div className={`w-full`}>
<SingleComponents/>
</div>
);
};
export default Single
We have to create lastly the Cart Screen,
Cart Screen (Cart.jsx)
In this screen, we have to import the cart components into the Cart screen.
import CartComponents from "../components/cart";
const Cart = () => {
return (
<div className={`w-full`}>
<CartComponents/>
</div>
);
};
export default Cart
Since the screens have been created we imported the 3 screens to the App.jsx file.
App.jsx
import React, { lazy, Suspense } from "react";
import { Route, Routes } from "react-router-dom";
import "./index.css";
const Home = lazy(() => import("./screens/Home"));
const Single = lazy(() => import("./screens/single"));
const Cart = lazy(() => import("./screens/cart"))
// // PaymentSuccess
export default function App() {
const [height, setHeight] = useState(0);
return (
<div className="based" style={{ height }}>
<Routes>
<Route
path="/"
element={
<Suspense fallback={<></>}>
<Home />
</Suspense>
}
/>
<Route
path="restaurant/:id"
element={
<Suspense fallback={<></>}>
<Single />
</Suspense>
}
/>
<Route
path="restaurant/cart"
element={
<Suspense fallback={<></>}>
<Cart />
</Suspense>
}
/>
</Routes>
</div>
);
}
Explanation:
So we have to lazy import the screens to optimize the app. Then we proceed to set up our route using the react-router-dom package we installed earlier.
Conclusion:
We have tried to see how we can carry out ACID transactions in Prisma ORM. We agreed on the idea behind the Prisma transaction query, its importance, and how it can be carried out in a Nodejs application. We illustrate this example with Reactjs by building different components and screens, to drive out our concept Home.
I know the whole process has been long, and I expect that you take your time going through the subject at hand. I appreciate your attention and the focus you have carried down to this place you are reading. I know this material will help you to be better at performing read and write operations atomically.
Thank you for your attention, and please if you experience any difficulty you can hit me up on my socials.
Subscribe to my newsletter
Read articles from Victor Essien directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Victor Essien
Victor Essien
As an experienced software developer with over 4 years of experience in software development; Seeking the philosopher stone has always been my drive in whatever I do, and software development is not left out of the picture. I am constantly aware of the rapid change of wave flow in software development. This continually reminds me of the need to learn every day to keep up with these changes in software development. My lifelong principle in software development is that there is always a solution to a problem. I earnestly believe in this approach (or you can view it as a mindset) when approaching a new problem that seems daunting. I don't settle with the view that nothing can be solved; So in software development when faced with a stifling task, I seek means for a solution to that problem no matter how it will take me. In all of this, I see myself as someone who is a dedicated hard-working software developer who is constantly striving to improve my skills and knowledge in the field. I always put my client in focus when developing applications that will fill in the needs of their quest.