DEV Community

Cover image for Cart Feature in ReactJS Using Zustand
Rifky Alfarez
Rifky Alfarez

Posted on

Cart Feature in ReactJS Using Zustand

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
Enter fullscreen mode Exit fullscreen mode
npm i axios zustand @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode
  • Create some files and make your folder structure look like this

Folder structure

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

Run the app and it will look like this:

app look like

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

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

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.

button clicked

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' }
  )
);

Enter fullscreen mode Exit fullscreen mode

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

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 the addCart action, increasing the quantity of the selected item.
  • Remove Item: Clicking the - button calls the removeCart 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.

final looks

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)

Collapse
 
difani_anjayani_ffbd2fd3c profile image
Difani Anjayani

this makes zustand much easier to understand!!! lof it!!🤩🙌