Build a Product Store with React Hooks
Build a shopping cart app using useReducer, useContext, and custom hooks.
Introduction
30 Second Summary
Every online store you have ever used has a shopping cart that updates instantly, tracks quantities, and shows totals across multiple pages. Behind the scenes, managing that shared state cleanly is one of the trickiest challenges in frontend development.
In this project, you will build a fully functional product store with a shopping cart from scratch using only React's built-in hooks. No external state management libraries needed.
What You'll Build
A React application with a product grid, real-time search filtering, and a shopping cart sidebar, all powered by advanced hooks like useReducer, useContext, useMemo, useCallback, and useTransition.
By the end of this project, you'll have:
- A shopping cart state machine powered by useReducer and useContext that any component can access without prop drilling.
- Reusable custom hooks (useCart and useFilteredProducts) that encapsulate complex logic into clean, composable abstractions.
- Performance optimizations using useMemo, useCallback, and React.memo that prevent unnecessary re-renders across the app.
- Secret Mission: Add a category filter dropdown and persist the cart to localStorage using a custom hook.
Are there any prerequisites?
This project assumes you are comfortable with basic React (components, props, useState, events), basic JavaScript (ES6 syntax, array methods), and HTML/CSS fundamentals.
You do not need prior experience with useReducer, useContext, or any of the advanced hooks covered here.
Scaffold the React Project
Before you can build anything, you need a working React project on your machine.
In this step, you will scaffold a new React app using Vite, a fast build tool that sets up your project files and dev server in seconds.
In this step, get ready to:
- Scaffold a new React project with Vite.
- Start the development server and verify it loads in your browser.
- Replace the default scaffold with a clean starting point for the product store.
Create the Vite + React project
Vite provides a scaffold command that generates all the boilerplate files a React app needs. You will run this command from your Desktop so the project folder is easy to find.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+`.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder by running this command:
npm create vite@latest product-store -- --template react
What does this command do?
- npm create vite@latest runs the Vite project generator at its latest version.
- product-store is the name of the folder it creates.
- --template react tells Vite to use the React JavaScript template (no TypeScript).
- Move into the new project folder and install its dependencies by running these commands:
cd product-store
npm install
You should see a progress bar as npm downloads the packages. Once it finishes, you will see a summary with the number of packages added.
Seeing a compatibility error?
Vite 8 requires Node.js version 20.19+ or 22.12+. Check your version by running:
node --version
If the number is below 20.19, download the latest LTS version from nodejs.org and run the installer.
Help me troubleshoot this
Start the dev server
Vite includes a development server that auto-refreshes your browser whenever you save a file. This gives you instant feedback as you build.
- Start the dev server by running:
npm run dev
- Open your browser and navigate to http://localhost:5173.
You should see the default Vite + React welcome page with a spinning logo and a counter button.
Don't see the page?
Make sure the terminal is still running npm run dev without errors. If port 5173 is already in use, Vite will pick a different port and display it in the terminal output.
Help me debug this
- Back in Cursor, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the product-store folder, then click Open.
Why open the folder separately?
Opening the project folder tells Cursor to treat product-store as the workspace root. This enables file browsing in the sidebar, correct path suggestions, and a terminal that starts in the right directory.
Clean up the scaffold files
The default Vite template includes demo content you do not need. You will replace three files with a minimal starting point for the product store.
- In the Cursor sidebar, expand the src folder and click App.jsx to open it.
- Select all the existing code and replace it with this:
function App() {
return (
<div>
<h1>Product Store</h1>
</div>
)
}
export default App
What does this code do?
This is the simplest possible React component. It renders a single heading as a placeholder. You will build out the full store layout in later steps.
- Save App.jsx.
- Open src/index.css from the sidebar.
- Select all the existing code and replace it with this:
:root {
font-family: Inter, system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #213547;
background-color: #f8f9fa;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
}
#root {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
What do these styles do?
- :root sets the base font, text color, and a light gray background for the entire page.
- The * reset removes default margin and padding from all elements so spacing is predictable.
- #root constrains the app to 1200px wide and centers it on the page.
- Save index.css.
- Open src/App.css from the sidebar.
- Select all the existing code and replace it with this:
.app {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header h1 {
font-size: 1.5rem;
color: #1a1a2e;
}
.cart-badge {
background: #4361ee;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
What do these styles do?
These classes style the header and cart badge you will build in Step 2. Adding them now means the layout will look correct as soon as you create those components.
- .app arranges the page vertically with consistent spacing between sections.
- .header creates a white bar with the title on the left and cart badge on the right.
- .cart-badge styles the item-count pill in blue with rounded corners.
- Save App.css.
- Switch to your browser. You should see a plain white page with Product Store as a heading in dark text on a light gray background.
Still seeing the old Vite page?
Make sure you saved all three files (App.jsx, index.css, App.css). Vite auto-refreshes, but only after you save. If the page still does not update, check the terminal for error messages.
Help me fix this
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here are the three files you should have at this point:
function App() {
return (
<div>
<h1>Product Store</h1>
</div>
)
}
export default App
:root {
font-family: Inter, system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #213547;
background-color: #f8f9fa;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
}
#root {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.app {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header h1 {
font-size: 1.5rem;
color: #1a1a2e;
}
.cart-badge {
background: #4361ee;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
Your project scaffold is ready and the dev server is running. Next up, you will build the shopping cart engine using useReducer and share it across all components with useContext.
Build the Cart with useReducer and useContext
Your project is scaffolded and the dev server is running. Now it's time to build the state architecture that powers the entire shopping cart.
Multiple components need to add items, remove items, and update quantities. You'll centralize all of that logic with useReducer and broadcast it globally with useContext.
In this step, get ready to:
- Create a cart state machine using useReducer with actions for adding, removing, and updating items.
- Build a custom useCart hook that wraps the context with a clean API.
- Connect cart state globally so any component can access it without prop drilling.
Create the cart context and reducer
The cart needs to handle three distinct operations: adding an item, removing an item, and changing an item's quantity. useReducer is perfect for this because it lets you define each operation as a named action, keeping the logic in one predictable place.
Why useReducer instead of useState?
With useState, you'd need multiple setter functions scattered across components, each manually computing the next state. useReducer centralizes all state transitions into a single function (the reducer) that receives the current state and an action, then returns the new state.
This pattern makes complex state predictable. Every change goes through the reducer, so you always know exactly how state can change.
- Create a new folder inside src called context.
- Inside src/context/, create a new file called CartContext.jsx.
- Add the imports, context creation, and cart reducer function by pasting this code into CartContext.jsx:
import { createContext, useReducer } from 'react'
// Create a context to share cart state across the app
export const CartContext = createContext(null)
// Reducer function handles all cart state transitions
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
// Check if item already exists in cart
const existingItem = state.items.find(item => item.id === action.payload.id)
if (existingItem) {
// Increase quantity instead of duplicating
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
}
}
// Add new item with quantity 1
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
}
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload.id),
}
case 'UPDATE_QUANTITY': {
// Remove item if quantity drops to 0 or below
if (action.payload.quantity <= 0) {
return {
...state,
items: state.items.filter(item => item.id !== action.payload.id),
}
}
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
),
}
}
default:
throw new Error(`Unknown action type: ${action.type}`)
}
}
What does this code do?
createContext(null) creates a React context object. Components that consume this context will be able to read the cart state from anywhere in the component tree.
The cartReducer function is a pure function that takes the current state and an action object, then returns a brand-new state. It never mutates the original state. Instead, it uses the spread operator (...state) to create immutable copies.
- ADD_ITEM checks if the product is already in the cart. If yes, it increments the quantity. If no, it adds the product with quantity: 1.
- REMOVE_ITEM filters out the item by ID.
- UPDATE_QUANTITY sets the exact quantity, or removes the item if quantity hits zero.
Now add the CartProvider component below the reducer in the same file. Paste this at the bottom of CartContext.jsx:
const initialState = { items: [] }
// Provider component wraps the app and shares cart state
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState)
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
)
}
What does CartProvider do?
useReducer returns the current state and a dispatch function. You call dispatch with an action object (like { type: 'ADD_ITEM', payload: product }) and the reducer computes the next state.
CartContext.Provider passes state and dispatch to every nested component. Any component inside CartProvider can read the cart without prop drilling.
- Save src/context/CartContext.jsx.
- Check your terminal. The dev server should still be running with no errors.
Seeing a syntax error in the terminal?
- Make sure the import statement at the top of the file includes both createContext and useReducer inside the curly braces.
- Check that every opening curly brace { in the switch cases has a matching closing brace }.
- Confirm you used backticks (not regular quotes) around the template literal in the default case.
Help me fix a syntax error in my CartContext.jsx file
Create the useCart custom hook
Raw dispatch calls force every component to know the exact shape of your action objects. A custom hook wraps that complexity into simple named functions like addItem and removeItem.
- Create a new folder inside src called hooks.
- Inside src/hooks/, create a new file called useCart.jsx.
- Add the custom hook by pasting this code into useCart.jsx:
import { useContext, useCallback, useMemo } from 'react'
import { CartContext } from '../context/CartContext.jsx'
export default function useCart() {
const context = useContext(CartContext)
// Safety check: throw if used outside CartProvider
if (!context) {
throw new Error('useCart must be used within a CartProvider')
}
const { state, dispatch } = context
// Wrap dispatch calls in useCallback for stable references
const addItem = useCallback((product) => {
dispatch({ type: 'ADD_ITEM', payload: product })
}, [dispatch])
const removeItem = useCallback((id) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id } })
}, [dispatch])
const updateQuantity = useCallback((id, quantity) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } })
}, [dispatch])
// Derive totals from state using useMemo
const totalItems = useMemo(() => {
return state.items.reduce((sum, item) => sum + item.quantity, 0)
}, [state.items])
const totalPrice = useMemo(() => {
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}, [state.items])
return {
items: state.items,
addItem,
removeItem,
updateQuantity,
totalItems,
totalPrice,
}
}
What does this hook do?
useContext(CartContext) reads the cart state from the nearest CartProvider. The safety check throws a clear error if someone accidentally uses the hook without wrapping the app in a provider.
useCallback wraps each action function so it keeps a stable reference between renders. This matters for performance when passing these functions to child components.
useMemo derives totalItems and totalPrice from the items array. These values only recalculate when state.items actually changes, avoiding unnecessary work on every render.
- Save src/hooks/useCart.jsx.
- Check your terminal for errors. You won't see a visual change yet because no component is using the hook.
Seeing 'Cannot find module' in the terminal?
- Make sure the import path '../context/CartContext.jsx' has the correct relative path. The hooks folder is a sibling of context, so ../ goes up one level from hooks into src.
- Confirm your file is named useCart.jsx (not useCart.js) to match the JSX extension used in the project.
Help me fix a module import error in useCart.jsx
Wire up the provider and build the Header
The reducer and hook are ready. Now you need to wrap the entire app in CartProvider so every component has access to the cart state.
- Open src/main.jsx in Cursor.
- Replace the entire contents of the file with this code:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import { CartProvider } from './context/CartContext.jsx'
import './index.css'
// Wrap App in CartProvider so all components can access cart state
createRoot(document.getElementById('root')).render(
<StrictMode>
<CartProvider>
<App />
</CartProvider>
</StrictMode>,
)
Why wrap at the top level?
By placing CartProvider in main.jsx (above App), every single component in your application tree can call useCart() and get the cart state. This is the Context API provider pattern.
- Save src/main.jsx.
Now build a Header component that proves the context is working by reading the cart's total item count.
- Create a new folder inside src called components.
- Inside src/components/, create a new file called Header.jsx.
- Add the Header component by pasting this code into Header.jsx:
import useCart from '../hooks/useCart.jsx'
// Header displays the store name and cart item count
export default function Header() {
const { totalItems } = useCart()
return (
<header className="header">
<h1>Product Store</h1>
<div className="cart-badge">
Cart ({totalItems})
</div>
</header>
)
}
What does this component do?
Header calls useCart() to pull totalItems from the cart context. The badge displays the count dynamically. Because totalItems is memoized, the Header only re-renders when the cart actually changes.
The .header and .cart-badge CSS classes are already defined in App.css from the previous step.
- Save src/components/Header.jsx.
- Switch back to your browser at http://localhost:5173. You won't see the Header rendered yet because App.jsx doesn't import it. That wiring happens in the next step when you build the full layout.
Getting 'useCart must be used within a CartProvider'?
- Open src/main.jsx and verify that <CartProvider> wraps <App />. If App sits outside the provider, components inside it cannot access the context.
- Make sure you saved main.jsx after editing. The dev server picks up changes on save.
Help me fix the CartProvider wrapping error
✔️ Awesome, I've got everything!
Great. Double check you've saved all three new files.
ⓧ I'd like to double check the full code
Here is the complete code for each file created in this step:
import { createContext, useReducer } from 'react'
export const CartContext = createContext(null)
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existingItem = state.items.find(item => item.id === action.payload.id)
if (existingItem) {
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
),
}
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
}
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(item => item.id !== action.payload.id),
}
case 'UPDATE_QUANTITY': {
if (action.payload.quantity <= 0) {
return {
...state,
items: state.items.filter(item => item.id !== action.payload.id),
}
}
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
),
}
}
default:
throw new Error(`Unknown action type: ${action.type}`)
}
}
const initialState = { items: [] }
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState)
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
)
}
import { useContext, useCallback, useMemo } from 'react'
import { CartContext } from '../context/CartContext.jsx'
export default function useCart() {
const context = useContext(CartContext)
if (!context) {
throw new Error('useCart must be used within a CartProvider')
}
const { state, dispatch } = context
const addItem = useCallback((product) => {
dispatch({ type: 'ADD_ITEM', payload: product })
}, [dispatch])
const removeItem = useCallback((id) => {
dispatch({ type: 'REMOVE_ITEM', payload: { id } })
}, [dispatch])
const updateQuantity = useCallback((id, quantity) => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } })
}, [dispatch])
const totalItems = useMemo(() => {
return state.items.reduce((sum, item) => sum + item.quantity, 0)
}, [state.items])
const totalPrice = useMemo(() => {
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}, [state.items])
return {
items: state.items,
addItem,
removeItem,
updateQuantity,
totalItems,
totalPrice,
}
}
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import { CartProvider } from './context/CartContext.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<CartProvider>
<App />
</CartProvider>
</StrictMode>,
)
import useCart from '../hooks/useCart.jsx'
export default function Header() {
const { totalItems } = useCart()
return (
<header className="header">
<h1>Product Store</h1>
<div className="cart-badge">
Cart ({totalItems})
</div>
</header>
)
}
Your cart's entire state architecture is in place. You have a reducer that handles every cart operation, a context that broadcasts state globally, and a custom hook that gives components a clean API. Next up, you'll build the product grid and cart sidebar to put this state to work.
Build the Product Grid and Cart Sidebar
In the last step, you built the state management layer. CartProvider, cartReducer, and the useCart hook handle all the cart logic behind the scenes. Now it's time to build the components that bring that logic to life on screen.
In this step, you'll create the product catalog, individual product cards with "Add to Cart" buttons, and a cart sidebar showing everything the user has added. Each component taps into the useCart hook to read and modify cart state directly.
In this step, get ready to:
- Create a product data file and product card component.
- Build the product grid and shopping cart sidebar.
- Compose everything into a two-column page layout.
Create the product data and ProductCard component
Every store needs an inventory. You'll start by creating a data file with 12 products, then build the card component that displays each one.
- Create a new folder called data inside your src folder.
- Create a new file called products.js inside src/data/ and paste the following product data:
export const products = [
{ id: 1, name: 'Wireless Headphones', price: 79.99, category: 'Electronics', image: '🎧' },
{ id: 2, name: 'Running Shoes', price: 129.99, category: 'Clothing', image: '👟' },
{ id: 3, name: 'Coffee Maker', price: 49.99, category: 'Home', image: '☕' },
{ id: 4, name: 'Bluetooth Speaker', price: 39.99, category: 'Electronics', image: '🔊' },
{ id: 5, name: 'Yoga Mat', price: 29.99, category: 'Home', image: '🧘' },
{ id: 6, name: 'Denim Jacket', price: 89.99, category: 'Clothing', image: '🧥' },
{ id: 7, name: 'Smart Watch', price: 199.99, category: 'Electronics', image: '⌚' },
{ id: 8, name: 'Backpack', price: 59.99, category: 'Clothing', image: '🎒' },
{ id: 9, name: 'Desk Lamp', price: 34.99, category: 'Home', image: '💡' },
{ id: 10, name: 'Sunglasses', price: 44.99, category: 'Clothing', image: '🕶️' },
{ id: 11, name: 'Portable Charger', price: 24.99, category: 'Electronics', image: '🔋' },
{ id: 12, name: 'Scented Candle', price: 19.99, category: 'Home', image: '🕯️' },
]
What does this data do?
This array is your store's product catalog. Each object has an id for unique identification, a name, price, category for filtering later, and an emoji as a placeholder image.
- Save src/data/products.js.
You won't see a change yet. The next chunk uses this data to render product cards.
- Now create a new file called ProductCard.jsx inside src/components/ and paste the following:
import { memo, useCallback, useMemo } from 'react'
import useCart from '../hooks/useCart.jsx'
const ProductCard = memo(function ProductCard({ product }) {
const { items, addItem } = useCart()
// Check if this product is already in the cart
const isInCart = useMemo(() => {
return items.some(item => item.id === product.id)
}, [items, product.id])
// Stable reference for the add button click handler
const handleAdd = useCallback(() => {
addItem(product)
}, [addItem, product])
return (
<div className="product-card">
<div className="product-image">{product.image}</div>
<h3>{product.name}</h3>
<p className="product-category">{product.category}</p>
<p className="product-price">${product.price.toFixed(2)}</p>
<button
className={`add-btn ${isInCart ? 'in-cart' : ''}`}
onClick={handleAdd}
>
{isInCart ? 'Add More' : 'Add to Cart'}
</button>
</div>
)
})
export default ProductCard
What does this component do?
ProductCard receives a single product prop and renders its details. It uses the useCart hook to access addItem and check if the product is already in the cart.
The memo wrapper prevents re-renders when the card's props haven't changed. useMemo caches the isInCart check, and useCallback stabilizes the click handler reference. These optimizations become important as the product list grows.
- Save src/components/ProductCard.jsx.
You won't see a change yet. ProductCard needs a parent component to render it.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is src/data/products.js:
export const products = [
{ id: 1, name: 'Wireless Headphones', price: 79.99, category: 'Electronics', image: '🎧' },
{ id: 2, name: 'Running Shoes', price: 129.99, category: 'Clothing', image: '👟' },
{ id: 3, name: 'Coffee Maker', price: 49.99, category: 'Home', image: '☕' },
{ id: 4, name: 'Bluetooth Speaker', price: 39.99, category: 'Electronics', image: '🔊' },
{ id: 5, name: 'Yoga Mat', price: 29.99, category: 'Home', image: '🧘' },
{ id: 6, name: 'Denim Jacket', price: 89.99, category: 'Clothing', image: '🧥' },
{ id: 7, name: 'Smart Watch', price: 199.99, category: 'Electronics', image: '⌚' },
{ id: 8, name: 'Backpack', price: 59.99, category: 'Clothing', image: '🎒' },
{ id: 9, name: 'Desk Lamp', price: 34.99, category: 'Home', image: '💡' },
{ id: 10, name: 'Sunglasses', price: 44.99, category: 'Clothing', image: '🕶️' },
{ id: 11, name: 'Portable Charger', price: 24.99, category: 'Electronics', image: '🔋' },
{ id: 12, name: 'Scented Candle', price: 19.99, category: 'Home', image: '🕯️' },
]
And here is src/components/ProductCard.jsx:
import { memo, useCallback, useMemo } from 'react'
import useCart from '../hooks/useCart.jsx'
const ProductCard = memo(function ProductCard({ product }) {
const { items, addItem } = useCart()
const isInCart = useMemo(() => {
return items.some(item => item.id === product.id)
}, [items, product.id])
const handleAdd = useCallback(() => {
addItem(product)
}, [addItem, product])
return (
<div className="product-card">
<div className="product-image">{product.image}</div>
<h3>{product.name}</h3>
<p className="product-category">{product.category}</p>
<p className="product-price">${product.price.toFixed(2)}</p>
<button
className={`add-btn ${isInCart ? 'in-cart' : ''}`}
onClick={handleAdd}
>
{isInCart ? 'Add More' : 'Add to Cart'}
</button>
</div>
)
})
export default ProductCard
Build the ProductGrid and Cart components
ProductCard knows how to display a single product. Now you need a grid component to render all 12 cards, and a cart component to show what the user has added.
- Create a new file called ProductGrid.jsx inside src/components/ and paste the following:
export default function ProductGrid({ products }) {
return (
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
import ProductCard from './ProductCard.jsx'
What does this component do?
ProductGrid takes a products array as a prop and maps over it, rendering one ProductCard for each item. The key={product.id} prop helps React efficiently update individual cards when the list changes.
- Save src/components/ProductGrid.jsx.
You won't see a change yet. The grid needs to be rendered inside App.jsx.
- Create a new file called Cart.jsx inside src/components/ and paste the following:
import useCart from '../hooks/useCart.jsx'
export default function Cart() {
const { items, removeItem, updateQuantity, totalPrice } = useCart()
// Show empty state when cart has no items
if (items.length === 0) {
return (
<div className="cart">
<h2>Shopping Cart</h2>
<p className="cart-empty">Your cart is empty</p>
</div>
)
}
return (
<div className="cart">
<h2>Shopping Cart</h2>
<ul className="cart-items">
{items.map(item => (
<li key={item.id} className="cart-item">
<div className="cart-item-info">
<span className="cart-item-name">{item.name}</span>
<span className="cart-item-price">${(item.price * item.quantity).toFixed(2)}</span>
</div>
<div className="cart-item-controls">
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
<span>{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
<button className="remove-btn" onClick={() => removeItem(item.id)}>Remove</button>
</div>
</li>
))}
</ul>
<div className="cart-total">
<strong>Total: ${totalPrice.toFixed(2)}</strong>
</div>
</div>
)
}
What does this component do?
Cart reads the cart state through the useCart hook. If the cart is empty, it shows a placeholder message. Otherwise, it lists each item with quantity controls (+ and - buttons) and a remove button.
The totalPrice value is derived state. It comes from the useMemo calculation inside the useCart hook, which recalculates only when state.items changes.
- Save src/components/Cart.jsx.
You won't see a change yet. The next substep wires both components into App.jsx.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is src/components/ProductGrid.jsx:
export default function ProductGrid({ products }) {
return (
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
import ProductCard from './ProductCard.jsx'
And here is src/components/Cart.jsx:
import useCart from '../hooks/useCart.jsx'
export default function Cart() {
const { items, removeItem, updateQuantity, totalPrice } = useCart()
if (items.length === 0) {
return (
<div className="cart">
<h2>Shopping Cart</h2>
<p className="cart-empty">Your cart is empty</p>
</div>
)
}
return (
<div className="cart">
<h2>Shopping Cart</h2>
<ul className="cart-items">
{items.map(item => (
<li key={item.id} className="cart-item">
<div className="cart-item-info">
<span className="cart-item-name">{item.name}</span>
<span className="cart-item-price">${(item.price * item.quantity).toFixed(2)}</span>
</div>
<div className="cart-item-controls">
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
<span>{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
<button className="remove-btn" onClick={() => removeItem(item.id)}>Remove</button>
</div>
</li>
))}
</ul>
<div className="cart-total">
<strong>Total: ${totalPrice.toFixed(2)}</strong>
</div>
</div>
)
}
Compose the layout in App.jsx
All the building blocks are ready. Now you'll wire them together in App.jsx and add the styles to create a two-column store layout.
- Open src/App.jsx and replace its entire contents with the following:
import Header from './components/Header.jsx'
import ProductGrid from './components/ProductGrid.jsx'
import Cart from './components/Cart.jsx'
import { products } from './data/products.js'
import './App.css'
function App() {
return (
<div className="app">
<Header />
<main className="main-content">
<div className="products-section">
<ProductGrid products={products} />
</div>
<aside className="cart-section">
<Cart />
</aside>
</main>
</div>
)
}
export default App
What does this layout do?
App imports the products array and passes it to ProductGrid. The main-content section uses CSS Grid to create two columns: the product grid on the left and the cart sidebar on the right.
Notice that Cart doesn't receive any props. It reads its state directly from Context via the useCart hook. This is the power of the Context pattern: no prop drilling needed.
- Save src/App.jsx.
The page will look unstyled. The next chunk adds the CSS to bring the layout together.
- Open src/App.css and replace its entire contents with the following complete stylesheet:
.app {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header h1 {
font-size: 1.5rem;
color: #1a1a2e;
}
.cart-badge {
background: #4361ee;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.main-content {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1.5rem;
align-items: start;
}
.products-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.product-card {
background: white;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.product-image {
font-size: 2.5rem;
text-align: center;
padding: 1rem 0;
}
.product-card h3 {
font-size: 0.95rem;
color: #1a1a2e;
}
.product-category {
font-size: 0.8rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.product-price {
font-size: 1.1rem;
font-weight: 700;
color: #4361ee;
}
.add-btn {
margin-top: auto;
padding: 0.6rem;
border: none;
border-radius: 6px;
background: #4361ee;
color: white;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover {
background: #3a56d4;
}
.add-btn.in-cart {
background: #2ec4b6;
}
.cart-section {
position: sticky;
top: 1rem;
}
.cart {
background: white;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.cart h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
color: #1a1a2e;
}
.cart-empty {
color: #888;
font-style: italic;
}
.cart-items {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cart-item {
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
}
.cart-item-info {
display: flex;
justify-content: space-between;
margin-bottom: 0.4rem;
}
.cart-item-name {
font-size: 0.9rem;
font-weight: 500;
}
.cart-item-price {
font-weight: 600;
color: #4361ee;
}
.cart-item-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cart-item-controls button {
width: 28px;
height: 28px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-weight: 600;
}
.cart-item-controls button:hover {
background: #f0f0f0;
}
.remove-btn {
width: auto !important;
padding: 0 0.5rem !important;
color: #e63946;
border-color: #e63946 !important;
font-size: 0.75rem;
margin-left: auto;
}
.cart-total {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 2px solid #eee;
text-align: right;
font-size: 1.1rem;
}
What do these styles do?
The .main-content rule creates a two-column CSS Grid layout: the left column stretches to fill available space (1fr) while the cart sidebar stays fixed at 320px.
The .product-grid uses auto-fill to automatically fit as many 200px-wide cards per row as the container allows. The .cart-section uses position: sticky so the cart stays visible as the learner scrolls the product grid.
- Save src/App.css and check your browser.
- You should see a grid of 12 product cards on the left, each with an emoji, name, category, price, and an "Add to Cart" button. On the right, you should see a "Shopping Cart" sidebar displaying "Your cart is empty".
- Click an "Add to Cart" button on any product. The header badge should update to "Cart (1)" and the cart sidebar should show the item with quantity controls.
Not seeing the product grid?
- Check that the import in ProductGrid.jsx points to './ProductCard.jsx' with the correct relative path.
- Make sure App.jsx imports { products } with curly braces (it's a named export, not a default export).
- Confirm all five new files are saved: products.js, ProductCard.jsx, ProductGrid.jsx, Cart.jsx, and App.jsx.
Help me debug why my product grid isn't rendering.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files.
ⓧ I'd like to double check the full code
Here is src/App.jsx:
import Header from './components/Header.jsx'
import ProductGrid from './components/ProductGrid.jsx'
import Cart from './components/Cart.jsx'
import { products } from './data/products.js'
import './App.css'
function App() {
return (
<div className="app">
<Header />
<main className="main-content">
<div className="products-section">
<ProductGrid products={products} />
</div>
<aside className="cart-section">
<Cart />
</aside>
</main>
</div>
)
}
export default App
And here is the full src/App.css:
.app {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header h1 {
font-size: 1.5rem;
color: #1a1a2e;
}
.cart-badge {
background: #4361ee;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.main-content {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1.5rem;
align-items: start;
}
.products-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.product-card {
background: white;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.product-image {
font-size: 2.5rem;
text-align: center;
padding: 1rem 0;
}
.product-card h3 {
font-size: 0.95rem;
color: #1a1a2e;
}
.product-category {
font-size: 0.8rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.product-price {
font-size: 1.1rem;
font-weight: 700;
color: #4361ee;
}
.add-btn {
margin-top: auto;
padding: 0.6rem;
border: none;
border-radius: 6px;
background: #4361ee;
color: white;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover {
background: #3a56d4;
}
.add-btn.in-cart {
background: #2ec4b6;
}
.cart-section {
position: sticky;
top: 1rem;
}
.cart {
background: white;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.cart h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
color: #1a1a2e;
}
.cart-empty {
color: #888;
font-style: italic;
}
.cart-items {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cart-item {
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
}
.cart-item-info {
display: flex;
justify-content: space-between;
margin-bottom: 0.4rem;
}
.cart-item-name {
font-size: 0.9rem;
font-weight: 500;
}
.cart-item-price {
font-weight: 600;
color: #4361ee;
}
.cart-item-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cart-item-controls button {
width: 28px;
height: 28px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-weight: 600;
}
.cart-item-controls button:hover {
background: #f0f0f0;
}
.remove-btn {
width: auto !important;
padding: 0 0.5rem !important;
color: #e63946;
border-color: #e63946 !important;
font-size: 0.75rem;
margin-left: auto;
}
.cart-total {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 2px solid #eee;
text-align: right;
font-size: 1.1rem;
}
Your store layout is live. You can browse products, add them to the cart, adjust quantities, and remove items. Next up, you'll add performance optimizations to make sure this stays smooth as the app grows.
Optimize with useMemo, useCallback, and React.memo
Your product store works, but there's an invisible problem. Every time the cart updates, React re-renders all 12 product cards even though their data hasn't changed.
In this step, you'll examine how the performance optimizations already built into your components work. You'll add a render log to prove that React.memo, useCallback, and useMemo are preventing unnecessary work.
In this step, get ready to:
- Observe how React.memo and useCallback prevent unnecessary re-renders in ProductCard.
- Understand how useMemo caches derived state like cart totals and the isInCart check.
- Learn how useRef will power the search input in the next step.
Add a render log to observe React.memo in action
Your ProductCard component is already wrapped in React.memo and uses useCallback for its click handler. To see these optimizations working, you'll add a log that prints every time the component renders.
- In src/components/ProductCard.jsx, find the opening line of the component function:
const ProductCard = memo(function ProductCard({ product }) {
const { items, addItem } = useCart()
- Add a console.log line between the function opening and the useCart() call, so it looks like this:
const ProductCard = memo(function ProductCard({ product }) {
console.log('ProductCard render:', product.name)
const { items, addItem } = useCart()
What does this log reveal?
Without React.memo, every cart update would cause all 12 product cards to re-render. You'd see 12 log lines each time you click a button.
With React.memo, React compares the previous props to the new props. If they haven't changed, the component skips rendering entirely. You'll only see logs for cards whose isInCart value actually changed.
- Save ProductCard.jsx.
- Open your browser's DevTools console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Click Add to Cart on any product. You should see only a few product names logged in the console, not all 12.
Why does useCallback help here?
Look at the handleAdd function in ProductCard. Without useCallback, React would create a brand new function object on every render. That new reference would make React.memo think props changed, defeating the optimization.
useCallback returns the same function reference between renders as long as its dependencies (addItem and product) haven't changed. This keeps React.memo's comparison working correctly.
Seeing all 12 cards log every time?
React's StrictMode in development intentionally double-renders components to help you find bugs. You may see more logs than expected. The important thing is that after the initial load, only the cards whose cart status changes should log on subsequent clicks.
Help me understand why all my ProductCards are re-rendering despite React.memo
Understand useMemo for derived state
Your app computes several values from the cart array: total item count, total price, and whether each product is already in the cart. Without caching, React would recalculate these on every single render.
What is useMemo?
useMemo caches the result of an expensive computation. It only recalculates when the values in its dependency array change. Think of it as a formula in a spreadsheet that only updates when the cells it references change.
Open src/hooks/useCart.jsx and look at the totalItems and totalPrice computations:
const totalItems = useMemo(() => {
return state.items.reduce((sum, item) => sum + item.quantity, 0)
}, [state.items])
const totalPrice = useMemo(() => {
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}, [state.items])
What does this code do?
Both totalItems and totalPrice loop through every cart item using reduce. Without useMemo, these loops would run on every render even if the cart hasn't changed.
The [state.items] dependency array tells React: only recalculate these totals when the items array actually changes. Any other state update (typing in a search box, toggling UI) skips the recalculation entirely.
Now look at the isInCart check inside src/components/ProductCard.jsx:
const isInCart = useMemo(() => {
return items.some(item => item.id === product.id)
}, [items, product.id])
Why memoize isInCart?
Each ProductCard checks whether its product exists in the cart array. With 12 cards and frequent renders, that's 12 array scans per render cycle. useMemo ensures each card only re-scans the array when items or product.id changes.
The result drives the button text: "Add to Cart" for new items and "Add More" for items already in the cart. The button also changes color via the in-cart CSS class.
- Return to your browser and add the same product to the cart twice.
- The button on that product card should show Add More with a teal background, while all other cards still show Add to Cart in blue.
When NOT to use useMemo
useMemo adds comparison overhead. For simple operations (accessing a single property, basic arithmetic), the comparison costs more than just recalculating. Reserve useMemo for array iterations, expensive computations, or values that feed into other memoized components.
Plan the useRef optimization for search
In the next step, you'll build a search bar that uses useRef for two purposes: auto-focusing the input on page load, and tracking the previous search term without causing re-renders.
What is useRef?
useRef creates a mutable container that persists across renders without triggering re-renders when it changes. It has two primary uses.
- DOM access: attach a ref to an element to call methods like .focus() directly.
- Value storage: store a value (like the previous search term) that survives re-renders but doesn't cause them.
Why not just use useState for the previous term?
Updating state causes a re-render. If you stored the previous search term in useState, setting it would trigger another render, which would set it again, creating an infinite loop. useRef updates silently in the background.
✔️ Awesome, I've got everything!
Great. Double check you've saved ProductCard.jsx with the console.log line added.
ⓧ I'd like to double check the full code
Here is the complete src/components/ProductCard.jsx file with the render log added:
import { memo, useCallback, useMemo } from 'react'
import useCart from '../hooks/useCart.jsx'
const ProductCard = memo(function ProductCard({ product }) {
console.log('ProductCard render:', product.name)
const { items, addItem } = useCart()
const isInCart = useMemo(() => {
return items.some(item => item.id === product.id)
}, [items, product.id])
const handleAdd = useCallback(() => {
addItem(product)
}, [addItem, product])
return (
<div className="product-card">
<div className="product-image">{product.image}</div>
<h3>{product.name}</h3>
<p className="product-category">{product.category}</p>
<p className="product-price">${product.price.toFixed(2)}</p>
<button
className={`add-btn ${isInCart ? 'in-cart' : ''}`}
onClick={handleAdd}
>
{isInCart ? 'Add More' : 'Add to Cart'}
</button>
</div>
)
})
export default ProductCard
Your product store now uses three layers of optimization: React.memo to skip re-renders, useCallback to stabilize function references, and useMemo to cache derived values. Next up, you'll build the search bar with useTransition and useRef to keep the UI responsive during filtering.
Add Search with useTransition and a Custom Hook
Your store looks great and runs efficiently thanks to the performance optimizations from the last step. However, users currently have no way to find specific products without scrolling through all 12 items.
In this step, you will build a search bar that filters products in real time. The challenge with search is that typing is an urgent action (the user expects instant character feedback), while filtering a list is a slower, non-urgent operation. React's useTransition hook lets you separate these two priorities so the input never freezes.
In this step, get ready to:
- Create a custom hook that filters products using useTransition and useMemo.
- Build a SearchBar component with useRef for auto-focus and previous term tracking.
- Wire the search into App.jsx so filtered results replace the raw product array.
Create the useFilteredProducts custom hook
A custom hook extracts reusable logic into its own function. Your useFilteredProducts hook will own the filtering logic so that any component can search products without reimplementing the same code.
- Create a new file at src/hooks/useFilteredProducts.jsx and paste the following code:
import { useMemo, useTransition } from 'react'
import { useState, useEffect } from 'react'
export default function useFilteredProducts(products, searchTerm) {
// useTransition marks the filter update as non-blocking
const [isPending, startTransition] = useTransition()
// Store a deferred copy of the search term
const [deferredTerm, setDeferredTerm] = useState(searchTerm)
useEffect(() => {
// Wrap the state update in startTransition so typing stays responsive
startTransition(() => {
setDeferredTerm(searchTerm)
})
}, [searchTerm, startTransition])
// Only recalculate filtered results when deferredTerm or products change
const filteredProducts = useMemo(() => {
if (!deferredTerm.trim()) return products
const lower = deferredTerm.toLowerCase()
return products.filter(
product =>
product.name.toLowerCase().includes(lower) ||
product.category.toLowerCase().includes(lower)
)
}, [products, deferredTerm])
return { filteredProducts, isPending }
}
What does this code do?
useTransition returns isPending (a boolean that's true while the transition runs) and startTransition (a function that wraps low-priority updates).
When the user types, React processes the keystroke immediately (urgent). The filter calculation happens inside startTransition, so React can interrupt it if another keystroke arrives. The useMemo then caches the filtered array, preventing unnecessary recalculations when other state (like the cart) changes.
Why useTransition instead of debouncing?
Debouncing delays the search by a fixed time (e.g. 300ms), which feels sluggish. useTransition lets React decide when to process the filter based on available resources. If the device is fast, the result appears instantly. If it is slow, React prioritizes keeping the input responsive.
- Save the file.
- You won't see a change yet. The next chunk builds the SearchBar component that uses this hook.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
import { useMemo, useTransition } from 'react'
import { useState, useEffect } from 'react'
export default function useFilteredProducts(products, searchTerm) {
const [isPending, startTransition] = useTransition()
const [deferredTerm, setDeferredTerm] = useState(searchTerm)
useEffect(() => {
startTransition(() => {
setDeferredTerm(searchTerm)
})
}, [searchTerm, startTransition])
const filteredProducts = useMemo(() => {
if (!deferredTerm.trim()) return products
const lower = deferredTerm.toLowerCase()
return products.filter(
product =>
product.name.toLowerCase().includes(lower) ||
product.category.toLowerCase().includes(lower)
)
}, [products, deferredTerm])
return { filteredProducts, isPending }
}
Build the SearchBar component
The SearchBar needs to handle two things: keep the text input snappy for the user, and communicate the search term up to the parent. It also uses useRef for two purposes: auto-focusing the input on page load and tracking the previous search term.
- Create a new file at src/components/SearchBar.jsx and paste the following code:
import { useRef, useEffect } from 'react'
export default function SearchBar({ searchTerm, onSearchChange }) {
// Ref for the input DOM element (auto-focus on mount)
const inputRef = useRef(null)
// Ref to track the previous search term without causing re-renders
const previousTermRef = useRef('')
// Auto-focus the search input when the page loads
useEffect(() => {
inputRef.current.focus()
}, [])
// Update previousTermRef after every render
useEffect(() => {
previousTermRef.current = searchTerm
})
return (
<div className="search-bar">
<input
ref={inputRef}
type="text"
placeholder="Search products..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchTerm && previousTermRef.current !== searchTerm && (
<span className="search-hint">
Searching for "{searchTerm}"
</span>
)}
</div>
)
}
What does this code do?
- inputRef stores a direct reference to the <input> DOM element. The empty-dependency useEffect calls .focus() once on mount so the cursor lands in the search box automatically.
- previousTermRef remembers what the search term was on the last render. Because refs update without triggering re-renders, this is the right tool for tracking "previous" values.
- The search hint ("Searching for...") only appears while the current term differs from the previous term, giving the user a brief visual cue that filtering is happening.
Why useRef instead of useState for the previous term?
If you stored the previous term in useState, updating it would trigger another re-render, which would update it again, creating an infinite loop. useRef updates silently. It holds a mutable value across renders without triggering new ones.
- Save the file.
- You won't see a change yet. The next chunk wires SearchBar into your app.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before continuing.
ⓧ I'd like to double check the full code
import { useRef, useEffect } from 'react'
export default function SearchBar({ searchTerm, onSearchChange }) {
const inputRef = useRef(null)
const previousTermRef = useRef('')
useEffect(() => {
inputRef.current.focus()
}, [])
useEffect(() => {
previousTermRef.current = searchTerm
})
return (
<div className="search-bar">
<input
ref={inputRef}
type="text"
placeholder="Search products..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchTerm && previousTermRef.current !== searchTerm && (
<span className="search-hint">
Searching for "{searchTerm}"
</span>
)}
</div>
)
}
Wire the search into App.jsx
Now you will connect your new hook and component into the main app. The ProductGrid currently receives the raw products array. After this change, it will receive the filtered results instead.
- Replace the entire contents of src/App.jsx with the following code:
import { useState } from 'react'
import Header from './components/Header.jsx'
import SearchBar from './components/SearchBar.jsx'
import ProductGrid from './components/ProductGrid.jsx'
import Cart from './components/Cart.jsx'
import { products } from './data/products.js'
import useFilteredProducts from './hooks/useFilteredProducts.jsx'
import './App.css'
function App() {
// Local state for the raw search input value
const [searchTerm, setSearchTerm] = useState('')
// Custom hook returns filtered products and a pending flag
const { filteredProducts, isPending } = useFilteredProducts(products, searchTerm)
return (
<div className="app">
<Header />
<main className="main-content">
<div className="products-section">
<SearchBar searchTerm={searchTerm} onSearchChange={setSearchTerm} />
{isPending && <div className="loading-indicator">Filtering...</div>}
<ProductGrid products={filteredProducts} />
</div>
<aside className="cart-section">
<Cart />
</aside>
</main>
</div>
)
}
export default App
What changed here?
- useState('') holds the raw text the user types. This is the "urgent" update that keeps the input responsive.
- useFilteredProducts(products, searchTerm) passes the raw products and the current search term into your custom hook, getting back the filtered list and the pending flag.
- The isPending flag conditionally renders a "Filtering..." message so the user knows the list is updating.
- ProductGrid now receives filteredProducts instead of the full products array.
- Save src/App.jsx.
Seeing an error in the browser?
- If you see a blank screen, check that both new files are saved and their paths are correct. The imports in App.jsx must match the exact file locations.
- Make sure you imported useState from 'react' at the top of App.jsx. Without it, React cannot track the search term.
Help me fix my App.jsx import or rendering error.
Next, add the styles for the search bar and loading indicator so they look polished.
- In src/App.css, find the .products-section rule block. Add the following styles directly below it:
.search-bar input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.search-bar input:focus {
border-color: #4361ee;
}
.search-hint {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
display: block;
}
.loading-indicator {
color: #4361ee;
font-size: 0.9rem;
font-style: italic;
}
What do these styles do?
- .search-bar input gives the search field full width, padding, and a subtle border that transitions to the brand blue on focus.
- .search-hint styles the brief "Searching for..." text below the input as a small, muted label.
- .loading-indicator styles the "Filtering..." text that appears while useTransition is pending.
- Save src/App.css and return to your browser.
- You should see a full-width search input above the product grid. Your cursor should be auto-focused inside it.
- Type head into the search box. The grid should filter down to show only "Wireless Headphones".
- Clear the input and type Electronics. All four electronics products should appear.
Search not filtering products?
- Confirm that App.jsx passes filteredProducts to ProductGrid, not the raw products array.
- Check that useFilteredProducts.jsx is inside the src/hooks/ folder. A mismatched path will cause an import error.
- Make sure you are comparing lowercase strings in the filter. The search is case-insensitive, so both the search term and the product name must be lowercased before comparison.
Help me debug why my search bar is not filtering products.
With only 12 products, will I notice useTransition?
With 12 items, filtering is instant. You will not see a visible delay. The pattern becomes critical when filtering hundreds or thousands of items, or when each item triggers expensive render trees. You are learning the correct architecture now so it scales when your product list grows.
✔️ Awesome, I've got everything!
Great. Double check both files are saved.
ⓧ I'd like to double check the full code
import { useState } from 'react'
import Header from './components/Header.jsx'
import SearchBar from './components/SearchBar.jsx'
import ProductGrid from './components/ProductGrid.jsx'
import Cart from './components/Cart.jsx'
import { products } from './data/products.js'
import useFilteredProducts from './hooks/useFilteredProducts.jsx'
import './App.css'
function App() {
const [searchTerm, setSearchTerm] = useState('')
const { filteredProducts, isPending } = useFilteredProducts(products, searchTerm)
return (
<div className="app">
<Header />
<main className="main-content">
<div className="products-section">
<SearchBar searchTerm={searchTerm} onSearchChange={setSearchTerm} />
{isPending && <div className="loading-indicator">Filtering...</div>}
<ProductGrid products={filteredProducts} />
</div>
<aside className="cart-section">
<Cart />
</aside>
</main>
</div>
)
}
export default App
.app {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header h1 {
font-size: 1.5rem;
color: #1a1a2e;
}
.cart-badge {
background: #4361ee;
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.9rem;
}
.main-content {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1.5rem;
align-items: start;
}
.products-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-bar input {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.search-bar input:focus {
border-color: #4361ee;
}
.search-hint {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
display: block;
}
.loading-indicator {
color: #4361ee;
font-size: 0.9rem;
font-style: italic;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.product-card {
background: white;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.product-image {
font-size: 2.5rem;
text-align: center;
padding: 1rem 0;
}
.product-card h3 {
font-size: 0.95rem;
color: #1a1a2e;
}
.product-category {
font-size: 0.8rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.product-price {
font-size: 1.1rem;
font-weight: 700;
color: #4361ee;
}
.add-btn {
margin-top: auto;
padding: 0.6rem;
border: none;
border-radius: 6px;
background: #4361ee;
color: white;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover {
background: #3a56d4;
}
.add-btn.in-cart {
background: #2ec4b6;
}
.cart-section {
position: sticky;
top: 1rem;
}
.cart {
background: white;
border-radius: 8px;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.cart h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
color: #1a1a2e;
}
.cart-empty {
color: #888;
font-style: italic;
}
.cart-items {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cart-item {
padding-bottom: 0.75rem;
border-bottom: 1px solid #eee;
}
.cart-item-info {
display: flex;
justify-content: space-between;
margin-bottom: 0.4rem;
}
.cart-item-name {
font-size: 0.9rem;
font-weight: 500;
}
.cart-item-price {
font-weight: 600;
color: #4361ee;
}
.cart-item-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cart-item-controls button {
width: 28px;
height: 28px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-weight: 600;
}
.cart-item-controls button:hover {
background: #f0f0f0;
}
.remove-btn {
width: auto !important;
padding: 0 0.5rem !important;
color: #e63946;
border-color: #e63946 !important;
font-size: 0.75rem;
margin-left: auto;
}
.cart-total {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 2px solid #eee;
text-align: right;
font-size: 1.1rem;
}
Your product store now has a real-time search that keeps the input responsive no matter how complex the filtering becomes. You have built three custom hooks across this project, each encapsulating reusable logic with a clean API. Next up, the Secret Mission challenges you to add category filtering and localStorage persistence.
Secret mission
Category Filter and localStorage Persistence
Your cart disappears every time you refresh the page. Real stores remember what you added. Can you build a custom hook that syncs cart state to localStorage and add a category filter dropdown that works alongside the search bar?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The product-store project folder on your Desktop (contains all source code and node_modules).
- The Vite dev server process running in your terminal.
- A product-store-cart entry in your browser's localStorage.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the project.
- Your dev server stays running at http://localhost:5173 until you close the terminal.
- Your cart data persists in localStorage across browser sessions.
- The product-store folder remains on your Desktop for future editing.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay intact so you can resume later.
- In the terminal where the dev server is running, press Ctrl+C to stop the Vite dev server.
- To restart later, open a terminal in the product-store folder and run npm run dev.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- In the terminal where the dev server is running, press Ctrl+C to stop the process.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/product-store
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\product-store
- Clear the localStorage entry by opening your browser's DevTools (F12), navigating to the Application tab, selecting Local Storage, and deleting the product-store-cart key.
Nice Work!
Nice Work!
Nice work! You just built a fully functional product store with a shopping cart, all powered by React's built-in hooks and zero external state management libraries.
You've learned how to:
- Built a shopping cart state machine using useReducer with typed actions and shared it across the entire app using useContext and the Context Provider pattern.
- Optimized rendering performance using useMemo for derived values, useCallback for stable function references, and React.memo to prevent unnecessary component re-renders.
- Created reusable custom hooks (useCart, useFilteredProducts) and used useTransition to keep the search input responsive while filtering products in the background.
- Secret Mission: Added category filtering and localStorage persistence using a custom useLocalStorage hook that syncs cart state across page refreshes.
Ready to quiz yourself?