PART 3 — Implementasi State Management Keranjang Belanja Menggunakan Zustand
Zustand merupakan salah satu state management library yang ringan, sederhana, dan sangat cocok digunakan untuk aplikasi skala kecil-menengah. Sebelum masuk ke implementasi, kamu bisa mempelajari dokumentasinya pada link berikut: https://zustand.docs.pmnd.rs/
Mari kira mulai bagaimana langkah-langkah detail nya.
- Membuat Struktur Store
import { create } from "zustand";
export const useCartStore = create(
(set, get) => ({
// state
})
);
- Membuat Struktur Dasar Store
import toast from "react-hot-toast";
import { create } from "zustand";
export const useCartStore = create(
(set, get) => ({
carts: [],
addToCart: (product) =>
set((state) => {
// jika stok produk kosong
if (!product.stock) {
toast.error("Maaf, produk ini sedang habis");
return state;
}
// jika produk sudah ada, tambahkan quantity (maksimal stok)
const existing = state.carts.find((item) => item.id === product.id);
if (existing) {
// jika jumlah produk di keranjang melebihi stok
if (existing.quantity + 1 > product.stock) {
toast.error("Maaf, jumlah produk di keranjang melebihi stok yang tersedia");
return state;
}
toast.success("Produk berhasil ditambahkan ke keranjang");
return {
carts: state.carts.map((item) =>
item.id === product.id
? {
...item,
quantity: item.quantity + 1
}
: item
),
};
}
// jika produk belum ada di keranjang
toast.success("Produk berhasil ditambahkan ke keranjang");
return {
carts: [
...state.carts,
{ ...product, quantity: 1, isSelect: false },
],
};
}),
updateCart: (cart) => {
set((state) => ({
carts: state.carts.map((item) =>
item.id === cart.id ? { ...item, ...cart } : item
),
}));
},
removeFromCart: (id) => {
toast.success("Produk berhasil dihapus dari keranjang");
set((state) => ({
carts: state.carts.filter((item) => item.id !== id),
}));
},
clearCart: () => {
toast.success("Produk berhasil di bersihkan dari keranjang");
set({ carts: [] });
},
buy: () => {
toast.success("Produk berhasil dibeli. Terimakasih sudah berbelanja di toko kami.");
set((state) => ({ carts: state.carts.filter((item) => !item.isSelect) }));
},
})
);
- Membuat Struktur Dasar Store
carts: [],
addToCart: (product) =>
set((state) => {
// jika stok produk kosong
if (!product.stock) {
toast.error("Maaf, produk ini sedang habis");
return state;
}
// jika produk sudah ada, tambahkan quantity (maksimal stok)
const existing = state.carts.find((item) => item.id === product.id);
if (existing) {
// jika jumlah produk di keranjang melebihi stok
if (existing.quantity + 1 > product.stock) {
toast.error("Maaf, jumlah produk di keranjang melebihi stok yang tersedia");
return state;
}
toast.success("Produk berhasil ditambahkan ke keranjang");
return {
carts: state.carts.map((item) =>
item.id === product.id
? {
...item,
quantity: item.quantity + 1
}
: item
),
};
}
// jika produk belum ada di keranjang
toast.success("Produk berhasil ditambahkan ke keranjang");
return {
carts: [
...state.carts,
{ ...product, quantity: 1, isSelect: false },
],
};
}), updateCart: (cart) => {
set((state) => ({
carts: state.carts.map((item) =>
item.id === cart.id ? { ...item, ...cart } : item
),
}));
},removeFromCart: (id) => {
toast.success("Produk berhasil dihapus dari keranjang");
set((state) => ({
carts: state.carts.filter((item) => item.id !== id),
}));
},clearCart: () => {
toast.success("Produk berhasil di bersihkan dari keranjang");
set({ carts: [] });
},buy: () => {
toast.success("Produk berhasil dibeli. Terimakasih sudah berbelanja di toko kami.");
set((state) => ({ carts: state.carts.filter((item) => !item.isSelect) }));
},- Menggunakan Store di Komponen
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { FaBagShopping } from "react-icons/fa6";
import Button from "../button/Button";
import { useCartStore } from "@/stores/cart.store";
import { useShallow } from "zustand/react/shallow";
export default function FrontpageNavbar() {
const [isOpenDropdown, setIsOpenDropdown] = useState(false);
const navbarRef = useRef(null);
const pathname = usePathname();
const { carts } = useCartStore(
useShallow((state) => ({
carts: state.carts,
}))
);
// click outside
useEffect(() => {
const handleClickOutside = (event) => {
if (navbarRef.current && !navbarRef.current.contains(event.target)) {
setIsOpenDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [navbarRef]);
return (
<nav
ref={navbarRef}
className="bg-white dark:bg-gray-900 fixed w-full z-20 top-0 start-0 border-b border-gray-200 dark:border-gray-600"
>
<div className="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<Link
href="/"
className="flex items-center space-x-3 rtl:space-x-reverse"
>
<img src="/favicon.ico" className="h-8" alt="Flowbite Logo" />
<span className="self-center text-lg sm:text-2xl font-semibold whitespace-nowrap dark:text-white">
Simple Ecommerce
</span>
</Link>
<div className="flex md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
<Link href="/carts">
<Button className={"text-xs"}>
<FaBagShopping className="size-4" />
<span>{carts.length}</span>
</Button>
</Link>
<button
data-collapse-toggle="navbar-sticky"
type="button"
className="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-sticky"
aria-expanded="false"
onClick={() => setIsOpenDropdown(!isOpenDropdown)}
>
<span className="sr-only">Open main menu</span>
<svg
className="w-5 h-5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 17 14"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M1 1h15M1 7h15M1 13h15"
/>
</svg>
</button>
</div>
<div
className={`items-center justify-between ${
isOpenDropdown ? "" : "hidden"
} w-full md:flex md:w-auto md:order-1`}
id="navbar-sticky"
>
<ul className="flex flex-col p-4 md:p-0 mt-4 font-medium border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li>
<Link
href="/"
className={`
block py-2 px-3 rounded-sm
${
pathname === "/"
? "text-white bg-blue-700 md:bg-transparent md:text-blue-700 md:dark:text-blue-500"
: "text-gray-900 hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:dark:hover:text-blue-500 dark:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent"
}
`}
>
Home
</Link>
</li>
<li>
<Link
href="/products"
className={`
block py-2 px-3 rounded-sm
${
pathname.startsWith("/products")
? "text-white bg-blue-700 md:bg-transparent md:text-blue-700 md:dark:text-blue-500"
: "text-gray-900 hover:bg-gray-100 md:hover:bg-transparent md:hover:text-blue-700 md:dark:hover:text-blue-500 dark:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent"
}
`}
>
Produk
</Link>
</li>
</ul>
</div>
</div>
</nav>
);
}
const { carts } = useCartStore(
useShallow((state) => ({
carts: state.carts,
}))
);<Link href="/carts">
<Button className={"text-xs"}>
<FaBagShopping className="size-4" />
<span>{carts.length}</span>
</Button>
</Link>Referensi video:
https://youtu.be/8Aj_dX8iPQ0?si=fdOSFJrRKk6ZWQwI&t=639
import Link from "next/link";
import React from "react";
import Button from "../button/Button";
import { FaBagShopping } from "react-icons/fa6";
import { useCartStore } from "@/stores/cart.store";
import { useShallow } from "zustand/react/shallow";
export default function ProductCard({ product }) {
const { addToCart } = useCartStore(
useShallow((state) => ({
addToCart: state.addToCart,
}))
);
return (
<div className="w-full bg-white border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700 hover:shadow-xl shadow-gray-300 transition-all duration-300 ">
<Link href={`/products/${product.id}`}>
<img
className="p-4 rounded-t-lg h-72 w-full object-contain"
src={product.image}
alt="product image"
/>
</Link>
<div className="px-5 pb-5 flex flex-col gap-2">
<Link href={`/products/${product.id}`}>
<h5 className="text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
{product.name}
</h5>
</Link>
<div className="flex justify-between gap-2 items-center">
<span className="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-blue-400 border border-blue-400">
{product.category.name}
</span>
<span className="text-gray-700 text-base">
{product.stock} Tersedia
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-gray-900 dark:text-white">
{new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(parseFloat(product.price))}
</span>
<Button
className={`text-xs ${product.stock === 0 && "opacity-50"}`}
onClick={() => addToCart(product)}
disabled={product.stock === 0}
>
<FaBagShopping className="size-4" />
Tambah ke Keranjang
</Button>
</div>
</div>
</div>
);
}
Di sini kita memanggil addToCart dari store yang sudah dibuat dan menggunakan nya di tombol tambah ke keranjang.
import { useCartStore } from "@/stores/cart.store";
import Link from "next/link";
import React from "react";
import { FaTrashAlt } from "react-icons/fa";
import { FaCircleMinus, FaCirclePlus } from "react-icons/fa6";
import { useShallow } from "zustand/react/shallow";
export default function ProductCartCard({ productCart }) {
const { removeFromCart, updateCart } = useCartStore(
useShallow((state) => ({
removeFromCart: state.removeFromCart,
updateCart: state.updateCart,
}))
);
return (
<div className="flex gap-4 bg-white px-4 py-6 rounded-md shadow-sm border border-gray-200">
<div className="flex items-center w-fit">
<input
id="checked-checkbox"
type="checkbox"
defaultChecked={productCart.isSelect}
onChange={() =>
updateCart({ ...productCart, isSelect: !productCart.isSelect })
}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
</div>
<div className="flex gap-6 sm:gap-4 max-sm:flex-col">
<div className="w-24 h-24 max-sm:w-24 max-sm:h-24 shrink-0">
<img
src={productCart.image}
alt="product image"
className="w-full h-full object-contain border rounded-sm border-gray-200"
/>
</div>
<div className="flex flex-col gap-4">
<div>
<Link href={`/products/${productCart.id}`}>
<h3 className="text-sm sm:text-base font-semibold text-slate-900 hover:underline">
{productCart.name}
</h3>
</Link>
<span className="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-blue-400 border border-blue-400">
{productCart.category.name}
</span>
</div>
<div className="mt-auto">
<h3 className="text-sm font-semibold text-slate-900">
{new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(parseFloat(productCart.price * productCart.quantity))}
</h3>
</div>
</div>
</div>
<div className="ml-auto flex flex-col">
<div className="flex items-start gap-4 justify-end">
<button
type="button"
onClick={() =>
confirm("Apakah anda yakin menghapus data ini?") &&
removeFromCart(productCart.id)
}
>
<FaTrashAlt className="text-red-500" />
</button>
</div>
<div className="flex items-center gap-3 mt-auto">
<button
type="button"
onClick={() =>
updateCart({ ...productCart, quantity: productCart.quantity - 1 })
}
disabled={productCart.quantity === 1}
>
<FaCircleMinus
className={`size-4 ${
productCart.quantity === 1 ? "text-gray-400" : "text-gray-800"
}`}
/>
</button>
<span className="font-semibold text-base leading-[18px]">
{productCart.quantity}
</span>
<button
type="button"
onClick={() =>
updateCart({ ...productCart, quantity: productCart.quantity + 1 })
}
disabled={productCart.quantity === productCart.stock}
>
<FaCirclePlus
className={`size-4 ${
productCart.quantity === productCart.stock
? "text-gray-400"
: "text-gray-800"
}`}
/>
</button>
</div>
</div>
</div>
);
}
import { useCartStore } from "@/stores/cart.store";
import React from "react";
import { useShallow } from "zustand/react/shallow";
export default function SummaryOrderCard() {
const { carts, buy } = useCartStore(
useShallow((state) => ({
carts: state.carts,
buy: state.buy,
}))
);
const SHIPMENT_COST = 0;
const subTotal = carts
.filter((item) => item.isSelect)
.reduce((total, item) => total + parseFloat(item.price) * item.quantity, 0);
const total = subTotal + SHIPMENT_COST;
const cartsIsSelect = carts.filter((item) => item.isSelect);
return (
<div className="bg-white rounded-md px-4 py-6 h-max shadow-sm border border-gray-200">
<ul className="text-slate-500 font-medium space-y-4">
<li className="flex flex-wrap gap-4 text-sm">
Subtotal{" "}
<span className="ml-auto font-semibold text-slate-900">
{new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(parseFloat(subTotal))}
</span>
</li>
<li className="flex flex-wrap gap-4 text-sm">
Pengiriman{" "}
<span className="ml-auto font-semibold text-slate-900">
{new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(parseFloat(SHIPMENT_COST))}
</span>
</li>
<hr className="border-slate-300" />
<li className="flex flex-wrap gap-4 text-sm font-semibold text-slate-900">
Total{" "}
<span className="ml-auto">
{new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(parseFloat(total))}
</span>
</li>
</ul>
<div className="mt-8 space-y-4">
<button
type="button"
className={`text-sm px-4 py-2.5 w-full font-medium tracking-wide bg-blue-800 hover:bg-blue-900 text-white rounded-md cursor-pointer ${
!cartsIsSelect.length && "opacity-50 cursor-not-allowed"
}`}
onClick={() =>
confirm("Anda yakin ingin membeli produk ini?") && buy()
}
disabled={!cartsIsSelect.length}
>
Beli Sekarang
</button>
</div>
</div>
);
}
"use client";
import FrontpageLayout from "@/components/layouts/FrontpageLayout";
import Button from "@/components/ui/button/Button";
import ProductCartCard from "@/components/ui/card/ProductCartCard";
import SummaryOrderCard from "@/components/ui/card/SummaryOrderCard";
import FetchError from "@/components/ui/error/FetchError";
import { useCartStore } from "@/stores/cart.store";
import Link from "next/link";
import { FaArrowRightLong } from "react-icons/fa6";
import { useShallow } from "zustand/react/shallow";
export default function CartPageContent() {
const { carts, clearCart } = useCartStore(
useShallow((state) => ({
carts: state.carts,
clearCart: state.clearCart,
}))
);
return (
<FrontpageLayout>
<h3 className="font-bold text-gray-800 text-3xl text-start px-4 mb-4">
Keranjang
</h3>
{!carts.length ? (
<div className="flex flex-col gap-4 items-center">
<FetchError text="Produk tidak ditemukan, silahkan tambahkan produk ke keranjang" />
<Link href={"/products"}>
<Button>
Belanja Sekarang
<FaArrowRightLong />
</Button>
</Link>
</div>
) : (
<>
<div
key={carts.length}
className="grid lg:grid-cols-3 lg:gap-x-8 gap-x-6 gap-y-8 mt-6 px-4"
>
<div className="lg:col-span-2 space-y-6">
{carts.map((cart, index) => (
<ProductCartCard key={index} productCart={cart} />
))}
</div>
<SummaryOrderCard />
</div>
<div className="p-4">
<button
type="button"
onClick={() =>
confirm("Apakah anda yakin ingin bersihkan keranjang?") &&
clearCart()
}
className="focus:outline-none text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-900"
>
Bersihkan Keranjang
</button>
</div>
</>
)}
</FrontpageLayout>
);
}
- Membuat Struktur Dasar Store
Sekarang silakan jalankan project dan coba fitur-fitur berikut:
- Menambahkan produk ke keranjang
- Melihat isi keranjang
- Mengubah jumlah produk
- Menghapus produk
- Melakukan "pembelian"
Jika berhasil, tampilannya akan seperti pada video demo berikut:
- Kenapa data Keranjang Hilang Saat Refresh?
Jika anda perhatikan di akhir video, ketika halaman di-refresh, data keranjang hilang.
Ini terjadi karena state Zustand hanya disimpan di memory, sehingga akan hilang saat browser reload.
Untuk mengatasi hal ini, kita harus menyimpan data keranjang di LocalStorage.
Untungnya Zustand menyediakan middleware yang sangat mudah digunakan: https://zustand.docs.pmnd.rs/middlewares/persist
- Menambahkan Persist ke Store
import toast from "react-hot-toast";
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useCartStore = create(
persist(
(set, get) => ({
carts: [],
addToCart: (product) =>
set((state) => {
// jika stok produk kosong
if (!product.stock) {
toast.error("Maaf, produk ini sedang habis");
return state;
}
// jika produk sudah ada, tambahkan quantity (maksimal stok)
const existing = state.carts.find((item) => item.id === product.id);
if (existing) {
// jika jumlah produk di keranjang melebihi stok
if (existing.quantity + 1 > product.stock) {
toast.error("Maaf, jumlah produk di keranjang melebihi stok yang tersedia");
return state;
}
toast.success("Produk berhasil ditambahkan ke keranjang");
return {
carts: state.carts.map((item) =>
item.id === product.id
? {
...item,
quantity: item.quantity + 1
}
: item
),
};
}
// jika produk belum ada di keranjang
toast.success("Produk berhasil ditambahkan ke keranjang");
return {
carts: [
...state.carts,
{ ...product, quantity: 1, isSelect: false },
],
};
}),
updateCart: (cart) => {
set((state) => ({
carts: state.carts.map((item) =>
item.id === cart.id ? { ...item, ...cart } : item
),
}));
},
removeFromCart: (id) => {
toast.success("Produk berhasil dihapus dari keranjang");
set((state) => ({
carts: state.carts.filter((item) => item.id !== id),
}));
},
clearCart: () => {
toast.success("Produk berhasil di bersihkan dari keranjang");
set({ carts: [] });
},
buy: () => {
toast.success("Produk berhasil dibeli. Terimakasih sudah berbelanja di toko kami.");
set((state) => ({ carts: state.carts.filter((item) => !item.isSelect) }));
},
}),
{
name: "carts-storage", // nama key di localStorage
}
)
);
Pada kode ini kita sudah:
- Menggunakan
persist() - Menyimpan data di LocalStorage
- Menggunakan key:
"carts-storage"
Sekarang cobalah kembali di browser.
Maka meskipun halaman di-refresh, data keranjang tetap tersimpan.
Hasilnya akan seperti pada video demo berikut:
Selamat! 🎉
Kita sudah berhasil mengimplementasikan:
- State management menggunakan Zustand
- Aksi tambah, hapus, update, clear cart, dan beli sekarang
- Integrasi store ke UI
- Persist untuk menyimpan data ke LocalStorage
Ini adalah part terakhir dari topik Simple Ecommerce dengan NextJs, Zustand, dan Laravel
Selanjutnya Anda bisa melakukan pengembangan lanjutan seperti:
- Menambahkan "Add to Cart" di halaman detail
- Membuat modal konfirmasi yang lebih bagus
- Membuat alur transaksi yang lebih realistis
- Integrasi autentikasi
- Dan lainnya sesuai dengan keinginan masing-masing

0 Comments
Posting Komentar