In this article, I want to share my approach to building a shopping cart feature in ReactJS using Zustand. But first, what is Zustand, and why did I choose it over Redux? Zustand is a lightweight state management library that provides a simple and intuitive API for managing state in React applications. Unlike Redux, which can often feel complex with its boilerplate-heavy setup, Zustand is much easier to use and allows you to focus more on building features rather than configuring state management. Its minimalistic approach makes it an excellent choice for implementing features like a shopping cart, without the overhead that comes with more complex libraries.
Requirements
- react v18.3.1
- zustand v5.0.1
- axios v1.7.8
- tanstack/react-query v5.62.0
Additionally, ensure you’re familiar with fetching and displaying data using Axios and React Query. If you’re not sure how to do this, feel free to check out my article here.
Project Setup
- Install react.js and required packages. Optionally, you can also integrate Tailwind CSS for styling.
npm create vite@latest
npm i axios zustand @tanstack/react-query
- Create some files and make your folder structure look like this
Fetch and Display Data
// src/api/fakeStoreApi.ts
import axios from 'axios';
const axiosInstance = axios.create({
baseURL: 'https://fakestoreapi.com',
});
export const getAllProducts = async () => {
try {
const response = await axiosInstance.get('/products');
return response.data;
} catch (error) {
console.error('Error fetching products:', error);
return null;
}
};
// src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import ProductList from './components/ProductList';
import Cart from './components/Cart';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<main className="bg-neutral-50 min-h-screen">
<div className="w-[1280px] mx-auto py-4">
<h1 className="text-[2rem] text-neutral-950 font-bold">
Product List:
</h1>
<div className="flex gap-x-4">
<div className="w-[80%]">
<ProductList />
</div>
<Cart />
</div>
</div>
</main>
</QueryClientProvider>
);
}
export default App;
// src/components/ProductList.tsx
import { useQuery } from '@tanstack/react-query';
import { getAllProducts } from '../api/fakeStoreApi';
type ProductProps = {
id: number;
title: string;
price: number;
category: string;
image: string;
};
export default function ProductList() {
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: getAllProducts,
});
return (
<div className="grid grid-cols-4 gap-4">
{products?.map((product: ProductProps) => (
<div
key={product.id}
className="bg-neutral-200 flex flex-col justify-between gap-y-2 p-2 rounded-md"
>
<img
src={product.image}
alt={product.title}
className="w-full h-[250px] object-cover rounded-t"
/>
<h3 className="text-[1rem] text-neutral-900 font-medium">
{product.title.length > 20
? `${product.title.slice(0, 20)}...`
: product.title}
</h3>
<div className="flex justify-between items-center">
<p className="text-[0.8rem] text-neutral-600">${product.price}</p>
<button className="bg-neutral-800 text-[0.8rem] text-neutral-100 p-1 rounded">
add to cart
</button>
</div>
</div>
))}
</div>
);
}
// src/components/Cart.tsx
export default function Cart() {
return (
<div className="h-fit w-[20%] bg-neutral-200 flex flex-col gap-y-2 p-2 rounded-md">
<h3 className="text-[1rem] text-neutral-950 font-semibold border-b border-neutral-400 pb-2">
Cart:
</h3>
<ul>
<li className="text-[0.9rem] text-neutral-800 flex justify-between">
<p>Product</p>
<div className="flex items-center gap-x-2">
<button>-</button>
<p>1</p>
<button>+</button>
</div>
<p>20</p>
</li>
</ul>
<div className="text-[0.9rem] text-neutral-900 font-medium flex justify-between border-t border-dashed border-neutral-400 pt-2">
<p>Total item:</p>
<p>Total Price:</p>
</div>
</div>
);
}
Run the app and it will look like this:
Store Setup
// src/store.ts
import { create } from 'zustand';
type CartItem = {
id: number;
title: string;
price: number;
quantity: number;
};
type CartState = {
count: number;
cart: CartItem[];
addCart: (item: CartItem) => void;
removeCart: (id: number) => void;
};
export const useCart = create<CartState>((set) => ({
count: 0,
cart: [],
addCart: (item) =>
set((state) => {
const existingItem = state.cart.find(
(cartItem) => cartItem.id === item.id
);
if (existingItem) {
return {
count: state.count + 1,
cart: state.cart.map((cartItem) =>
cartItem.id === item.id
? { ...cartItem, quantity: cartItem.quantity + 1 }
: cartItem
),
};
}
return {
count: state.count + 1,
cart: [...state.cart, { ...item, quantity: 1 }],
};
}),
removeCart: (id) =>
set((state) => {
const existingItem = state.cart.find((item) => item.id === id);
if (existingItem && existingItem.quantity > 1) {
return {
count: state.count - 1,
cart: state.cart.map((cartItem) =>
cartItem.id === id
? { ...cartItem, quantity: cartItem.quantity - 1 }
: cartItem
),
};
}
return {
count: state.count - 1,
cart: state.cart.filter((item) => item.id !== id),
};
}),
}),
}));
count: 0
represents the initial total number of items in the shopping cart, starting at zero. cart: []
is an empty array that initially holds no items, intended to store the list of products added to the cart as objects.
The addCart
function first checks if the item already exists in the cart by using the find
method to search for an item with the same id
. If the item exists, its quantity
is increased by 1, and the total item count (count
) is also incremented. If the item does not exist in the cart, it is added as a new item with an initial quantity
of 1, and the total item count (count
) is increased by 1.
On the other hand, the removeCart
function first checks if the item to be removed exists in the cart using the find
method. If the item exists and its quantity
is greater than 1, the function reduces the quantity
by 1 and decreases the total item count (count
) by 1. However, if the quantity
is 1, the item is completely removed from the cart using the filter
method, and the total count is adjusted accordingly.
In the code, count
represents the total number of items in the cart across all product types, regardless of their uniqueness. It’s used to quickly display the total item count (e.g., "Total Items: 4") without recalculating. quantity
, on the other hand, is specific to each product and tracks how many units of a particular item are in the cart. Together, they allow efficient management of cart data: count
for global item totals and quantity
for individual item tracking.
To use the addCart
function from the useCart
store in the ProductList
component, you need to call addCart
with the appropriate product details when the "add to cart" button is clicked. Here's how you can do it:
// src/components/ProductList.tsx
import { useQuery } from '@tanstack/react-query';
import { getAllProducts } from '../api/fakeStoreApi';
import { useCart } from '../store';
type ProductProps = {
id: number;
title: string;
price: number;
category: string;
image: string;
};
export default function ProductList() {
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: getAllProducts,
});
// add this
const addCart = useCart((state) => state.addCart);
const cart = useCart((state) => state.cart);
console.log(cart);
return (
<div className="grid grid-cols-4 gap-4">
{products?.map((product: ProductProps) => (
<div
key={product.id}
className="bg-neutral-200 flex flex-col justify-between gap-y-2 p-2 rounded-md"
>
<img
src={product.image}
alt={product.title}
className="w-full h-[250px] object-cover rounded-t"
/>
<h3 className="text-[1rem] text-neutral-900 font-medium">
{product.title.length > 20
? `${product.title.slice(0, 20)}...`
: product.title}
</h3>
<div className="flex justify-between items-center">
<p className="text-[0.8rem] text-neutral-600">${product.price}</p>
<button
// add this
onClick={() =>
addCart({
...product,
price: product.price,
quantity: 1,
})
}
className="bg-neutral-800 text-[0.8rem] text-neutral-100 p-1 rounded"
>
add to cart
</button>
</div>
</div>
))}
</div>
);
}
const cart = useCart((state) => state.cart) console.log(cart)
it is not needed and only used for debugging, you can delete it again.
Then try to reopen your application and try clicking the add to cart button then open the console.
In the image, it can be seen that the data has been stored in the array, but the problem is that when it is refreshed, the data will disappear. To solve this problem persist
is needed, let's try to modify our file store.
// src/store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type CartItem = {
id: number;
title: string;
price: number;
quantity: number;
};
type CartState = {
count: number;
cart: CartItem[];
addCart: (item: CartItem) => void;
removeCart: (id: number) => void;
};
export const useCart = create<CartState>()(
// add this
persist(
(set) => ({
count: 0,
cart: [],
addCart: (item) =>
set((state) => {
const existingItem = state.cart.find(
(cartItem) => cartItem.id === item.id
);
if (existingItem) {
return {
count: state.count + 1,
cart: state.cart.map((cartItem) =>
cartItem.id === item.id
? { ...cartItem, quantity: cartItem.quantity + 1 }
: cartItem
),
};
}
return {
count: state.count + 1,
cart: [...state.cart, { ...item, quantity: 1 }],
};
}),
removeCart: (id) =>
set((state) => {
const existingItem = state.cart.find((item) => item.id === id);
if (existingItem && existingItem.quantity > 1) {
return {
count: state.count - 1,
cart: state.cart.map((cartItem) =>
cartItem.id === id
? { ...cartItem, quantity: cartItem.quantity - 1 }
: cartItem
),
};
}
return {
count: state.count - 1,
cart: state.cart.filter((item) => item.id !== id),
};
}),
}),
// add this
{ name: 'cart-storage' }
)
);
The persist middleware
in Zustand is used to automatically save the state
of your store in localStorage
(or another storage solution, like sessionStorage). This allows the state to persist across page reloads, meaning the user won't lose their cart data if they refresh the page or close and reopen the browser. { name: 'cart-storage' }
This defines the key under which the store's state will be saved in the storage. In this case, the cart data will be stored under the key cart-storage
in localStorage
.
Display Cart Data
Modify cart file to display the data
// src/components/Cart.tsx
import { useShallow } from 'zustand/shallow';
import { useCart } from '../store';
export default function Cart() {
const { count, cart, addCart, removeCart } = useCart(
useShallow((state) => ({
count: state.count,
cart: state.cart,
addCart: state.addCart,
removeCart: state.removeCart,
}))
);
// const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0); // manually calculate total items
const totalItems = count;
const totalPrice = cart.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return (
<div className="h-fit w-[20%] bg-neutral-200 flex flex-col gap-y-2 p-2 rounded-md">
<h3 className="text-[1rem] text-neutral-950 font-semibold border-b border-neutral-400 pb-2">
Cart:
</h3>
<ul>
{cart.map((item) => (
<li
key={item.id + item.title}
className="text-[0.9rem] text-neutral-800 flex justify-between"
>
<p>
{item.title.length > 10
? `${item.title.slice(0, 10)}...`
: item.title}
</p>
<div className="flex items-center gap-x-2">
<button onClick={() => removeCart(item.id)}>-</button>
<p>{item.quantity}</p>
<button onClick={() => addCart(item)}>+</button>
</div>
<p>${(item.price * item.quantity).toFixed(2)}</p>
</li>
))}
</ul>
<div className="text-[0.9rem] text-neutral-900 font-medium flex justify-between border-t border-dashed border-neutral-400 pt-2">
<p>Total Items:</p>
<p>{totalItems}</p>
</div>
<div className="text-[0.9rem] text-neutral-900 font-medium flex justify-between">
<p>Total Price:</p>
<p>${totalPrice.toFixed(2)}</p>
</div>
</div>
);
}
The useCart
custom hook is used to access the cart's state and actions (count
, cart
, addCart
, and removeCart
) using the useShallow
selector to optimize re-renders by subscribing only to the required parts of the state. This ensures the component doesn't re-render unnecessarily when unrelated parts of the state update.
-
Add Item: Clicking the
+
button calls theaddCart
action, increasing the quantity of the selected item. -
Remove Item: Clicking the
-
button calls theremoveCart
action, reducing the quantity. If the quantity is 1 and the button is clicked, the item is removed entirely from the cart.
The difference in calculating the total items using count
versus reduce
lies in efficiency and accuracy. Count
is more efficient since it retrieves a pre-stored value, making it ideal for apps with frequent renders or large carts, but it depends on accurate state updates. On the other hand, reduce
dynamically calculates the total from the cart data, ensuring accuracy but at the cost of slightly reduced performance for larger carts. Ultimately, the choice depends on your preference and whether you prioritize efficiency or dynamic accuracy.
Conclusion
I hope this article has been helpful and provided valuable insights for your development journey. I’m always open to suggestions and feedback, so please feel free to share your thoughts in the comments section below. You can find the full source code for this project on my GitHub repository. Thank you for taking the time to read, and I look forward to hearing from you!
References:
https://zustand.docs.pmnd.rs/
Top comments (1)
this makes zustand much easier to understand!!! lof it!!🤩🙌