Implement an add to cart feature in React
In this tutorial we will create a simple webshop for books using our free E-book api, and then focus on creating an add to cart function where we will add items to the cart, and then read them in a checkout view.
Note that we will focus on the functional part in this tutorial and not on the design, to make the article a little bit shorter 😇
Techniques to be used:
- React
- JavaScript/TypeScript
- localStorage
- context
- Third party API (Our E-book API)
Set up project
Let's start by setting up our react project.
npx create-react-app webshop-books --template typescript cd webshop-books npm start
Set up our context
We will begin with our context, which will store all our cart items. The reason that we are using the context api, is because we want the cart to be available across the app and easy accessible.
In the src folder, create a new folder called contexts and a file called Cart.tsx in the new contexts folder.
Create our context
In Cart.tsx we will begin with creating our context with our skeleton of what we want to provide. We need to store the items, add an item, remove an item and also clearing the whole cart.
import { createContext, useState } from "react"; interface ICartContext { items: any[]; addItem: (item: any) => void; removeItem: (id: string) => void; clear: () => void; } export const CartContext = createContext<ICartContext>({ items: [], addItem: (item: any) => console.log(item), removeItem: (id: string) => console.log(id), clear: () => "", });
And then, we will create our provider, that will wrap the application so it can utilize our context. And also implement our functions.
We'll start by creating our provider, and then we will create our functions afterwards.
In Cart.tsx add following:
interface ICartContextProps { children: JSX.Element | JSX.Element[]; } export const CartContextProvider = ({ children }: ICartContextProps) => { const [items, setItems] = useState<any[]>([]); return ( <CartContext.Provider value={{ items, addItem, removeItem, clear }}> {children} </CartContext.Provider> ); };
And then, we will implenent our addItem function as below:
const addItem = (item: any) => { const currentItems = [...items]; const exists = currentItems.find((cur: any) => cur.id === item.id); if (!exists) { currentItems.push(item); setItems(currentItems); } };
We are always checking if the item is not already in the cart, just to be sure. If it is not existing, we are adding it to the items array.
Our removeItem will look as below. We are simply filtering out the item from the items array and setting the items with the new value.
const removeItem = (id: string) => { const filtered = items.filter((cur: any) => cur.id !== id); setItems(filtered); };
And our clear function will basically set the items state to an empty array
const clear = () => setItems([]);
Full code of Cart.tsx
import { createContext, useState } from "react"; interface ICartContext { items: any[]; addItem: (item: any) => void; removeItem: (id: string) => void; clear: () => void; } export const CartContext = createContext<ICartContext>({ items: [], addItem: (item: any) => console.log(item), removeItem: (id: string) => console.log(id), clear: () => "", }); interface ICartContextProps { children: JSX.Element | JSX.Element[]; } export const CartContextProvider = ({ children }: ICartContextProps) => { const [items, setItems] = useState<any[]>([]); const addItem = (item: any) => { const currentItems = [...items]; const exists = currentItems.find((cur: any) => cur.id === item.id); if (!exists) { currentItems.push(item); setItems(currentItems); } }; const removeItem = (id: string) => { const filtered = items.filter((cur: any) => cur.id !== id); setItems(filtered); }; const clear = () => setItems([]); return ( <CartContext.Provider value={{ items, addItem, removeItem, clear }}> {children} </CartContext.Provider> ); };
Use our context
In order to make our context available for the application, we need to wrap the application in the provider. Let's head over to the index.tsx file and add following:
import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { CartContextProvider } from "./contexts/Cart"; import "./index.css"; const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement ); root.render( <React.StrictMode> <CartContextProvider> <App /> </CartContextProvider> </React.StrictMode> );
Create our App.tsx
For this simple webshop, we will let our App.tsx do most of the logic to keep the tutorial somewhat slim. We will basically do the following parts:
- Call the API
- Show the books
- Show the check out view
Call the API
We will begin with setting up our state where we will store the response from the API, and then we will implement our fetch function to get our books.
In App.tsx, begin with removing all the boilerplate code and add following state:
const [books, setBooks] = useState<any[]>([]);
And then we will create a useEffect hook where we will call the API from the hook.
We will use our E-book API as I stated in the beginning, and we will narrow the results down to only show books by J.K Rowling to make the example easier to work with. We also need to add a dummy price to our data, since the API does not have such information, we will also map the identifier to be easier to maintain in the code.
useEffect(() => { const fetchData = async () => { const response = await fetch( "https://api.algobook.info/v1/ebooks/author/J.K rowling" ); const data = await response.json(); const mappedBooks = data .map((book: any, idx: number) => { return { ...book, id: book.identifiers.find((id: any) => id.type === "ISBN_13") ?.identifier, price: (idx + 1) * 20, }; }) .filter((book: any) => book.id); setBooks(mappedBooks); }; fetchData(); }, []);
So what we are doing now, is that when we get the response, we will map the data so it will get a price and then we are mapping the id from the data so we can easier identify it from our context functions. Don't worry to much about the mapping part here, this is just so our example data gets a little bit easier to work with.
The App.tsx should look like this right now:
import { useEffect, useState } from "react"; import "./App.css"; function App() { const [books, setBooks] = useState<any[]>([]); useEffect(() => { const fetchData = async () => { const response = await fetch( "https://api.algobook.info/v1/ebooks/author/J.K%20rowling" ); const data = await response.json(); const mappedBooks = data .map((book: any, idx: number) => { return { ...book, id: book.identifiers.find((id: any) => id.type === "ISBN_13") ?.identifier, price: (idx + 1) * 20, }; }) .filter((book: any) => book.id); setBooks(mappedBooks); }; fetchData(); }, []); if (!books.length) { return <span>Loading...</span>; } return ( <div className="App"> <div className="content"></div> </div> ); } export default App;
Book component
Now when we have our books retrieved from the API, we should render them. Let's create a component for the book items, called Book.tsx and a stylesheet called Book.css in our src folder.
In our Book.tsx we will start by setting up some interfaces for what our Book component expects to get in the props.
import "./Book.css"; interface IBook { id: string; imgUrl: string; title: string; price: number; } interface IBookProps { book: IBook; onClick: (inCart: boolean, book: any) => void; }
Next, we want to check if our book exists in the cart, and set the text of the button and call the onClick accordingly. For this, we will use our context.
import { CartContext } from "./contexts/Cart"; const { items } = useContext(CartContext); const inCart = items.find((item) => item.id === book.id);
And at last, we will render it like this:
export const Book = ({ book, onClick }: IBookProps) => { const { items } = useContext(CartContext); const inCart = items.find((item) => item.id === book.id); return ( <div className="book"> <img alt="book-thumbnail" className="img" src={book.imgUrl} /> <h1 className="title">{book.title}</h1> <span className="price">{`Price: $${book.price}`}</span> <button className={`button ${inCart ? "button-remove" : ""}`} onClick={() => onClick(inCart, book)} > {inCart ? "Remove from cart" : "Add to cart"} </button> </div> ); };
Full code of Book.tsx
import { useContext } from "react"; import "./Book.css"; import { CartContext } from "./contexts/Cart"; interface IBook { id: string; imgUrl: string; title: string; price: number; } interface IBookProps { book: IBook; onClick: (inCart: boolean, book: any) => void; } export const Book = ({ book, onClick }: IBookProps) => { const { items } = useContext(CartContext); const inCart = items.find((item) => item.id === book.id); return ( <div className="book"> <img alt="book-thumbnail" className="img" src={book.imgUrl} /> <h1 className="title">{book.title}</h1> <span className="description">{book.localizedDescription}</span> <span className="price">{`Price: $${book.price}`}</span> <button className={`button ${inCart ? "button-remove" : ""}`} onClick={() => onClick(inCart, book)} > {inCart ? "Remove from cart" : "Add to cart"} </button> </div> ); };
Style the Book component
In our Book.css, we will apply the following style:
.book { display: flex; flex-direction: column; width: 250px; border-radius: 8px; box-shadow: 0rem 0.25rem 1rem RGB(0 0 0 / 10%); padding: 0.5rem 1rem; } .title { font-size: 18px; font-weight: 600; margin-top: 1rem; } .img { align-self: center; width: 80%; max-width: 240px; height: 130px; border-radius: 4px; margin-top: 0.5rem; object-fit: contain; } .price { margin-top: 1rem; font-size: 15px; font-weight: bold; } .button { display: flex; justify-content: center; border: none; padding: 12px 24px; text-decoration: none; min-width: 90px; margin-top: 2rem; white-space: nowrap; font-size: 14px; font-weight: 550; border-radius: 4px; letter-spacing: 0.05em; cursor: pointer; } .button-remove { background-color: rgb(170, 7, 7); color: white; } .button:hover { opacity: 0.7; }
Render our books
Now it's time to render our books from the App.tsx component. So let's open up App.tsx again, and add following code in our return statement:
return ( <div className="App"> <h1>Books by J.K Rowling</h1> <div className="content"> {books.map((book) => ( <Book key={book.id} book={book} onClick={(inCart: boolean, book: any) => handleClick(inCart, book)} /> ))} </div> </div> );
And then, we will create our handleClick function to handle the logic to add/remove items from the cart. In our App.tsx, we add following:
const { removeItem, addItem } = useContext(CartContext); const handleClick = (inCart: boolean, book: any) => { if (inCart) { removeItem(book.id); } else { addItem(book); } };
And now, our book should be added to the cart when we click on the buttons from the Book component.
Style our App.tsx
Let's apply some basic styling for our App component as well. Head over to App.css and remove the boilerplate style, and add following:
.App { text-align: center; padding: 1rem; } .content { display: flex; flex-wrap: wrap; gap: 1rem; }
And now, you should see something like this on your screen.
That's part 1 of this tutorial. Now we have all our functionality in place for the cart. But we also want to read the items and display in some sort of checkout view.
Checkout view
Let's create a new component that we will call Checkout.tsx and a stylesheet called Checkout.css. Our checkout component will read the items from the context state, and then display them along with the total price. We will also make the component able to clear the cart of the items and removing individual items.
Let's set up our component.
import { useContext } from "react"; import { CartContext } from "./contexts/Cart"; import "./Checkout.css"; export const Checkout = () => { const { items, removeItem, clear } = useContext(CartContext); if (!items.length) { return null; } return <div />; };
So if we don't have any items, we will return null. Now, let's handle the scenario where we have books in the cart.
We will calculate the total price using the reduce function.
const totalPrice = items.reduce((prev, book) => { return prev + book.price; }, 0);
And then, we will render our books that we have in the cart and add our onClick functions as well
return ( <div> <h2>Checkout</h2> <div className="books"> <div> <h3>{`Total price: $${totalPrice}`}</h3> <button onClick={clear}>Clear items</button> </div> {items.map((book) => ( <div className="item"> <img alt="book" src={book.imgUrl} className="image" /> <span>{book.title}</span> <span>${book.price}</span> <button onClick={() => removeItem(book.id)}>Remove</button> </div> ))} </div> </div> );
Full code of Checkout.tsx
import { useContext } from "react"; import { CartContext } from "./contexts/Cart"; import "./Checkout.css"; export const Checkout = () => { const { items, removeItem, clear } = useContext(CartContext); if (!items.length) { return null; } const totalPrice = items.reduce((prev, book) => { return prev + book.price; }, 0); return ( <div> <h2>Checkout</h2> <div className="books"> <div> <h3>{`Total price: $${totalPrice}`}</h3> <button onClick={clear}>Clear items</button> </div> {items.map((book) => ( <div className="item"> <img alt="book" src={book.imgUrl} className="image" /> <span>{book.title}</span> <span>${book.price}</span> <button onClick={() => removeItem(book.id)}>Remove</button> </div> ))} </div> </div> ); };
Styling Checkout component
And in our Checkout.css we add following:
.books { display: flex; border: 1px solid black; border-radius: 8px; padding: 1rem; gap: 1rem; } .image { width: 60px; height: 60px; } .item { display: flex; flex-direction: column; justify-content: center; align-items: center; }
Consume it
And in our App.tsx, we can call it from the return statement like below:
<div className="App"> <Checkout /> .... </div>
And now, on the top of the screen when you start selecting books, it should look something like this (I warned you about the design 😃)
LocalStorage
All right, so our book shop works fine and we can add/remove and clear our cart accordingly. But if we refresh the page, our cart will disappear. Let's store each item in localStorage, and read it when the App.tsx is mounted and set the items in our useEffect.
Cart context
In our Cart context, add following code to the add and remove functions
const addItem = (item: any) => { const currentItems = [...items]; const exists = currentItems.find((cur: any) => cur.id === item.id); if (!exists) { currentItems.push(item); setItems(currentItems); localStorage.setItem("cart", JSON.stringify(currentItems)); } }; const removeItem = (id: string) => { const filtered = items.filter((cur: any) => cur.id !== id); localStorage.setItem("cart", JSON.stringify(filtered)); setItems(filtered); }; const clear = () => { localStorage.setItem("cart", JSON.stringify([])); setItems([]); };
Note that we are using JSON.stringify, that is because setItem requires a string in the second argument.
Then we will add an additional function to our context, that will allow us to add an array of books, and not only one by one. We need this function since we will read back the full array from the localStorage and then set the state on page refreshes.
In our interface add:
interface ICartContext { .... initialize: (items: any) => void; }
And set it up in the createContext
export const CartContext = createContext<ICartContext>({ ... initialize: (items: any[]) => console.log(items), });
And the in the provider, implement the function
const initialize = (items = []) => setItems(items);
And at last, we will return it in the provider
return ( <CartContext.Provider value={{ items, addItem, removeItem, clear, initialize }} > {children} </CartContext.Provider> );
Full context code
import { createContext, useState } from "react"; interface ICartContext { items: any[]; addItem: (item: any) => void; removeItem: (id: string) => void; clear: () => void; initialize: (items: any) => void; } export const CartContext = createContext<ICartContext>({ items: [], addItem: (item: any) => console.log(item), removeItem: (id: string) => console.log(id), clear: () => "", initialize: (items: any[]) => console.log(items), }); interface ICartContextProps { children: JSX.Element | JSX.Element[]; } export const CartContextProvider = ({ children }: ICartContextProps) => { const [items, setItems] = useState<any[]>([]); const addItem = (item: any) => { const currentItems = [...items]; const exists = currentItems.find((cur: any) => cur.id === item.id); if (!exists) { currentItems.push(item); setItems(currentItems); localStorage.setItem("cart", JSON.stringify(currentItems)); } }; const removeItem = (id: string) => { const filtered = items.filter((cur: any) => cur.id !== id); localStorage.setItem("cart", JSON.stringify(filtered)); setItems(filtered); }; const initialize = (items = []) => setItems(items); const clear = () => { localStorage.setItem("cart", JSON.stringify([])); setItems([]); }; return ( <CartContext.Provider value={{ items, addItem, removeItem, clear, initialize }} > {children} </CartContext.Provider> ); };
App
In our App.tsx useEffect hook, we will read from localStorage, and if we have items, we will set the items with the stored ones with our new function
Extend our usage of the context with the initialize function
const { removeItem, addItem, initialize } = useContext(CartContext);
And in our useEffect hook, we add following code
const storedItems = localStorage.getItem("cart"); if (storedItems) { const parsed = JSON.parse(storedItems); initialize(parsed); }
Full code of App.tsx
import { useContext, useEffect, useState } from "react"; import "./App.css"; import { Book } from "./Book"; import { CartContext } from "./contexts/Cart"; import { Checkout } from "./Checkout"; function App() { const [books, setBooks] = useState<any[]>([]); const { removeItem, addItem, initialize } = useContext(CartContext); useEffect(() => { const fetchData = async () => { const response = await fetch( "https://api.algobook.info/v1/ebooks/author/J.K rowling" ); const data = await response.json(); const mappedBooks = data .map((book: any, idx: number) => { return { ...book, id: book.identifiers.find((id: any) => id.type === "ISBN_13") ?.identifier, price: (idx + 1) * 20, }; }) .filter((book: any) => book.id); setBooks(mappedBooks); }; fetchData(); const storedItems = localStorage.getItem("cart"); if (storedItems) { const parsed = JSON.parse(storedItems); initialize(parsed); } }, []); const handleClick = (inCart: boolean, book: any) => { if (inCart) { removeItem(book.id); } else { addItem(book); } }; if (!books.length) { return <span>Loading...</span>; } return ( <div className="App"> <Checkout /> <h1>Books by J.K Rowling</h1> <div className="content"> {books.map((book) => ( <Book key={book.id} book={book} onClick={(inCart: boolean, book: any) => handleClick(inCart, book)} /> ))} </div> </div> ); } export default App;
And now, when you refresh the page after you have selected your books, the checkout component should still show you the selected items.
Summary
In this tutorial, we showed how we can implement an add to cart feature using React and the Context API. We also covered how we can integrate to a third party API and also how we can make our cart data "persistent" using localStorage.
Do you think something (except for the design) should be done differently? How would you have solved it? Hit us up on our contact page with your thoughts.
Have a great day, and thanks for reading!