I have a demo here
I have a simple list of products and a cart that I would like to add the products to.
The Products and Cart are separate components in the index file.
I have the function to add the products to the cart in the Products components but how do I pass this to the Cart component that is outside the Products component.
import React, { useState } from "react";
import { render } from "react-dom";
import Cart from "./Cart";
import Products from "./Products";
import "./style.css";
const App = () => {
return (
<div>
<Products />
<Cart />
</div>
);
};
render(<App />, document.getElementById("root"));
https://stackblitz.com/edit/react-ts-txpsds
// index.tsx
import React from "react";
import { render } from "react-dom";
import Cart from "./Cart";
import { CartProvider } from "./context";
import Products from "./Products";
import "./style.css";
const App = () => {
return (
<CartProvider>
<div>
<Products />
<Cart />
</div>
</CartProvider>
);
};
render(<App />, document.getElementById("root"));
// Products.tsx
import React, { createContext, useCallback, useContext, useState } from "react";
import { AddCartContext } from "./context";
import { IProduct } from "./interface";
const Products = () => {
const addItems = useContext(AddCartContext);
const items = [
{
id: 1,
name: "Product One",
price: 20
},
{
id: 2,
name: "Product Two",
price: 56
},
{
id: 3,
name: "Product Three",
price: 13
}
];
const handleClick = (
e: React.MouseEvent<HTMLInputElement, MouseEvent>,
item: IProduct
) => {
e.preventDefault();
addItems(item);
};
const listItems = items.map(item => (
<div key={item.id}>
{`${item.name}: £${item.price}`}
<input type="submit" value="+" onClick={e => handleClick(e, item)} />
</div>
));
return (
<div>
<div>
<h2>Products</h2>
{listItems}
</div>
</div>
);
};
export default Products;
const Cart = () => {
const items = useContext(CartContext);
const cartItems = items.map((item, index) => (
<div key={index}>{`${item.name}: £${item.price}`}</div>
));
return (
<div>
<h2>Cart</h2>
{cartItems}
</div>
);
};
// context.tsx
import React, { createContext, useCallback, useRef, useState } from "react";
export const CartContext = createContext([]);
export const AddCartContext = createContext(item => {});
export function CartProvider(props) {
const [items, setItems] = useState([]);
const itemsRef = useRef(items);
itemsRef.current = items;
return (
<AddCartContext.Provider
value={useCallback(item => {
setItems([...itemsRef.current, item]);
}, [])}
>
<CartContext.Provider value={items}>
{props.children}
</CartContext.Provider>
</AddCartContext.Provider>
);
}
There are 2 work-arounds for your problem.
You can make the Cart component as the child component of Products through which you can pass the addToCart() as Props to Cart. [but it is not meaningful]
You can bring the state from Product Component to App i.e make the App as a stateful component and for products and Cart, make them as statelesss. Pass the data and methods as props.
For the second option, check the link.
If you want to share a property or function between multiple components you need to put that property or function in closest parent of those components so you can pass them as props.
In your case try to add your function to your App Component and then pass the function to both Products and Cart Components
Take a look at the react docs for Lifting state up.
Move your cart state up into the closest common ancestor - App.
From App, pass cart and setCart as props into both Products and Cart as needed.
import React, { useState, Dispatch, SetStateAction } from "react";
import { render } from "react-dom";
interface IProduct {
id: number;
name: string;
price: number;
}
const App = () => {
const [cart, setCart] = useState<IProduct[]>([]);
return (
<div>
<Products cart={cart} setCart={setCart} />
<Cart cart={cart} />
</div>
);
};
function Cart({ cart = [] }: { cart: IProduct[] }) {
return (
<div>
<h2>Cart</h2>
{cart.map(item => (
<div>{`${item.name}: £${item.price}`}</div>
))}
</div>
);
}
function Products({
cart,
setCart
}: {
cart: IProduct[];
setCart: Dispatch<SetStateAction<IProduct[]>>;
}) {
const items: IProduct[] = [{id: 1,name: "Product One",price: 20},{id: 2,name: "Product Two",price: 56},{id: 3,name: "Product Three",price: 13}];
const handleClick = (
e: React.MouseEvent<HTMLInputElement, MouseEvent>,
item: IProduct
) => {
e.preventDefault();
setCart([...cart, item]);
};
return (
<div>
<div>
<h2>Products</h2>
{items.map(item => (
<div>
{`${item.name}: £${item.price}`}
<input
type="submit"
value="+"
onClick={e => setCart([...cart, item])}
/>
</div>
))}
</div>
</div>
);
}
render(<App />, document.getElementById("root"));
Stackblitz
Keep a common items variable and a function addItems in App.tsx and you need to pass this function as prop to Product component which when adds a product will call this same function in App.tsx file and update the items list.And this items list can be passed to the Cart component.
Here check this live demo:https://stackblitz.com/edit/react-ts-qq5cea?file=index.tsx
I improved your code a little, the main thing that needed to be done was to move the state to a higher level
https://stackblitz.com/edit/react-ts-iicy7v?file=Shop.tsx
const App = () => {
return (
<div>
<Shop Products={Products} Cart={Cart} />
</div>
);
};
move logic to Shop:
const Shop = ({ Products, Cart }) => {
const [cart, setCart] = useState([]);
const addToCart = (item: IProduct) => {
setCart([...cart, item]);
};
const removeFromCart = (item: IProduct) => {
const itemWillBeRemoved = cart.find(e => e.id === item.id);
const index = cart.indexOf(itemWillBeRemoved);
const newCart = [...cart];
newCart.splice(index, 1);
setCart(newCart);
};
const items = [
{
id: 1,
name: "Product One",
price: 20
},
{
id: 2,
name: "Product Two",
price: 56
},
{
id: 3,
name: "Product Three",
price: 13
}
];
return (
<div>
<Products items={items} addToCart={addToCart} />
<Cart items={cart} removeFromCart={removeFromCart} />
</div>
);
};
But the best way - use State Management
Redux
Related
I have a context api in my application. At the moment I only keep the categories and i have 1 initial category. I print the categories in App.js with the map function. I have defined a function called addCategoryHandler in context api and I want to update my state by calling it in AddCategory component. But when I click the button state.categories returns undefined. I guess I'm missing something about lifecyle but I couldn't quite understand. Can you help?
Here is the codesandbox link: https://codesandbox.io/s/hungry-zeh-kwolr8
App.js
import AvailableProducts from './components/AvailableProducts.js';
import Category from './components/Category.js';
import Review from './components/Review.js';
import AddCategory from './components/AddCategory';
import { useAppContext } from './context/appContext';
import './assets/styles/App.scss';
export default function App() {
const { categories } = useAppContext();
return (
<main>
<h1>Initial Screen</h1>
<div className='container'>
<div className='container__left-side'>
<AvailableProducts />
<Review />
</div>
<div className='container__right-side'>
{categories.map((category) => (
<Category
key={category.id}
id={category.id}
title={category.title}
/>
))}
<AddCategory />
</div>
</div>
</main>
);
}
Context Api
import React, { useContext, useState } from "react";
import generateCategoryTitle from "../utils/GenerateCategoryTitle";
const AppContext = React.createContext();
const initialState = {
categories: [{ id: 1, title: "Category 1", products: [] }]
};
const AppProvider = ({ children }) => {
const [state, setState] = useState(initialState);
console.log(state);
const addCategoryHandler = () => {
// const { newId, newCategoryTitle } = generateCategoryTitle(state.categories);
// // const newCategory = [{ id: newId, title: newCategoryTitle, products: [] }];
// setState((prevState) => {
// console.log([...prevState.categories,...newCategory]);
// });
console.log("add category clicked");
};
return (
<AppContext.Provider value={{ ...state, addCategoryHandler }}>
{children}
</AppContext.Provider>
);
};
const useAppContext = () => useContext(AppContext);
export { AppProvider, useAppContext };
Add Category Component
import "../assets/styles/AddCategory.scss";
import { useAppContext } from "../context/appContext";
const AddCategory = () => {
const { addCategoryHandler } = useAppContext();
return (
<button
className="add-categoryn-btn"
type="button"
onClick={addCategoryHandler}
>
Add Category
</button>
);
};
export default AddCategory;
My context is as follows:
import React, {createContext, useEffect, useState} from "react";
export const CartContext = createContext();
const CartContextProvider = (props) => {
const [cart, setCart] = useState(JSON.parse(localStorage.getItem('cart')) || []);
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(cart));
}, [cart]);
const updateCart = (productId, op) => {
let updatedCart = [...cart];
if (updatedCart.find(item => item.id === productId)) {
let objIndex = updatedCart.findIndex((item => item.id === productId));
if (op === '-' && updatedCart[objIndex].qty > 1) {
updatedCart[objIndex].qty -= 1;
} else if (op === '+') {
updatedCart[objIndex].qty += 1;
}
} else {
updatedCart.push({id: productId, qty: 1})
}
setCart(updatedCart);
}
const removeItem = (id) => {
setCart(cart.filter(item => item.id !== id));
};
return (
<CartContext.Provider value={{cart, updateCart, removeItem}}>
{props.children}
</CartContext.Provider>
)
};
export default CartContextProvider;
App.js:
import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import NavigationBar from "./components/layout/navigationBar/NavigationBar";
import Homepage from "./pages/homepage/Homepage";
import AboutUsPage from "./pages/aboutUs/AboutUsPage";
import ContactPage from "./pages/contact/ContactPage";
import SearchPage from "./pages/search/SearchPage";
import ShoppingCart from "./components/layout/shoppingCart/ShoppingCart";
import CartContextProvider from "./context/CartContext";
function App() {
return (
<div>
<CartContextProvider>
<Router>
<NavigationBar/>
<ShoppingCart/>
<Routes>
<Route exact path="/" element={<Homepage/>}/>
<Route path="/a-propos" element={<AboutUsPage/>} />
<Route path="/contact" element={<ContactPage/>}/>
<Route path="/recherche" element={<SearchPage/>}/>
</Routes>
</Router>
</CartContextProvider>
</div>
);
}
export default App;
In the component ShoppingCart I am using another component ShoppingCartQuantity which in turn makes use of the context. It works as it should.
Here's the ShoppingCartQuantity component:
import React, {useContext} from "react";
import {CartContext} from "../../../context/CartContext";
import styles from './ShoppingCartQuantity.module.css'
const ShoppingCartQuantity = ({productId}) => {
const {cart, updateCart} = useContext(CartContext);
let qty = 0;
if (cart.find((item => item.id === productId))) {
let objIndex = cart.findIndex((item => item.id === productId));
qty = cart[objIndex].qty;
}
return (
<div>
<span>
<span className={`${styles.op} ${styles.decrementBtn}`} onClick={() => updateCart(productId, '-')}>-</span>
<span className={styles.qty}>{qty}</span>
<span className={`${styles.op} ${styles.incrementBtn}`} onClick={() => updateCart(productId, '+')}>+</span>
</span>
</div>
)
}
export default ShoppingCartQuantity;
Now I am trying to use the ShoppingCartQuantity component in the Homepage component which is a route element (refer to App.js) but getting the error Uncaught TypeError: Cannot destructure property 'cart' of '(0 , react__WEBPACK_IMPORTED_MODULE_0__.useContext)(...)' as it is undefined.
So the context is working for components outside the router but not for those inside it. If I have wrapped the router within the provider, shouldn't all the route elements get access to the context or am I missing something?
UPDATE
As user Build Though suggested in the comments, I tried using the ShoppingCartQuantity component in another route element and it works fine; so the problem is not with the router!
Below is the code of how I am using the ShoppingCartQuantity component in the Homepage component:
import React, { useState, useEffect, useRef } from "react";
import { Responsive, WidthProvider } from "react-grid-layout";
import Subcat from "../../components/subcat/Subcat";
import CategoryService from "../../services/api/Category";
import SubCategoryService from "../../services/api/SubCategory";
import CategoriesLayout from "../../utils/CategoriesLayout";
import CategoryCard from "../../components/category/CategoryCard";
import { Triangle } from 'react-loader-spinner'
import ScrollIntoView from 'react-scroll-into-view'
import ProductService from "../../services/api/Product";
import Swal from 'sweetalert2'
import withReactContent from 'sweetalert2-react-content';
import YouTube from 'react-youtube';
import FavoriteBtn from "../../components/favorite/FavoriteBtn";
import ShoppingCartQuantity from "../../components/layout/shoppingCart/ShoppingCartQuantity";
import "./Homepage.css";
import "../../components/product/ProductModal.css"
import "react-loader-spinner";
import modalStyles from "../../components/product/ProductModal.module.css"
function Homepage() {
const [categories, setCategories] = useState([]);
const [subCats, setSubCats] = useState([]);
const [loader, setLoader] = useState(false);
const ResponsiveGridLayout = WidthProvider(Responsive);
const scrollRef = useRef();
const productModal = withReactContent(Swal);
const opts = {
// height: '390',
// width: '640',
playerVars: {
autoplay: 1,
}
};
useEffect(() => {
CategoryService.get().then((response) => {
setCategories(response);
});
}, []);
function showSubCatsHandler(catId) {
setLoader(true);
setSubCats([]);
SubCategoryService.get(catId).then((response) => {
setSubCats(response.data);
setLoader(false);
scrollRef.current.scrollIntoView({ behavior: "smooth" });
});
}
function showProductPopupHandler(productId) {
ProductService.get(productId).then((response) => {
const product = response.data;
return productModal.fire({
html:
<div>
<h3 className={modalStyles.header}>{product.AMP_Title}</h3>
<h4 className={`${modalStyles.price} ${modalStyles.header}`}>{"CHf " + product.AMP_Price}</h4>
<img className={modalStyles.image} src={process.env.REACT_APP_BACKEND_BASE_URL + 'images/products/' + product.AMP_Image} />
{
product.descriptions.map((desc, _) => (
<div key={desc.AMPD_GUID}>
{
desc.AMPD_Title === '1' && <h4 className={modalStyles.header}>{product.AMP_Title}</h4>
}
{
desc.AMPD_Image !== '' && <img src={process.env.REACT_APP_BACKEND_BASE_URL + 'images/descriptions/' + desc.AMPD_Image} className={desc.AMPD_Alignment === 'left' ? modalStyles.descImageLeft : modalStyles.descImageRight} />
}
<p className={modalStyles.description}>{desc.AMPD_Description}</p>
</div>
))
}
<br/>
<div>
<FavoriteBtn productId={product.AMP_GUID}/>
<ShoppingCartQuantity productId={product.AMP_GUID} />
</div>
<br/>
{
product.AMP_VideoId !== '' &&
<YouTube
videoId={product.AMP_VideoId}
opts={opts}
/>
}
</div>,
showConfirmButton: false,
showCloseButton: true
});
});
}
return (
<div>
<div className="categories-container">
<ResponsiveGridLayout
className="layout"
layouts={ CategoriesLayout }
breakpoints={ { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 } }
cols={ { lg: 8, md: 8, sm: 6, xs: 4, xxs: 2 } }
isDraggable={ false }
>
{
categories.map((cat, index) => (
<div key={index}>
<CategoryCard
category_id = {cat.AMC_GUID}
image = {cat.AMC_Image}
showSubCatsHandler = {showSubCatsHandler}
/>
</div>
))
}
</ResponsiveGridLayout>
{
loader &&
<Triangle
height="100"
width="100"
color='#bcad70'
ariaLabel='loading'
wrapperClass="loader"
/>
}
<div ref={scrollRef}>
{
Object.keys(subCats).map((keyName, _) => (
<Subcat
key={subCats[keyName].AMSC_GUID}
title={ subCats[keyName].AMSC_Title }
products={ subCats[keyName].products }
showProductPopupHandler = {showProductPopupHandler}
/>
))
}
</div>
</div>
</div>
);
}
export default Homepage;
I am using the component in a SweetAlert popup. I guess it's the SweetAlert component that is not getting access to the context. Does anyone have an idea how to pass the context to the SweetAlert component?
UPDATE 2
The accepted solution works great except for 1 small issue: the ShoppingCartQuantity component was not re-rendering inside the SweetAlert popup and the qty would not change visually.
I updated the component by using the qty as a state.
const ShoppingCartQuantity = ({ qty, productId, updateCart }) => {
const [quantity, setQuantity] = useState(qty);
const updateCartHandler = (productId, amount) => {
updateCart(productId, amount);
setQuantity(Math.max(quantity + amount, 1));
}
return (
<div>
<span>
<span
className={`${styles.op} ${styles.decrementBtn}`}
onClick={() => updateCartHandler(productId, -1)}
>
-
</span>
<span className={styles.qty}>{quantity}</span>
<span
className={`${styles.op} ${styles.incrementBtn}`}
onClick={() => updateCartHandler(productId, 1)}
>
+
</span>
</span>
</div>
)
}
Issue
It's very likely that the sweet alert component is rendered outside your app, and thus, outside the CartContextProvider provider. I just searched the repo docs if there is a way to specify a root element, but this doesn't seem possible since this sweet alert code isn't React specific.
See this other similar issue regarding accessing a Redux context in the alert.
Solution
It doesn't seem possible ATM to access the context value from within the modal, so IMHO a workaround could be to refactor your ShoppingCartQuantity component into a wrapper container component to access the context and a presentation component to receive the context values and any callbacks.
I suggest also just passing the amount you want to increment/decrement the quantity by to updateCart instead of passing a "+"/"-" string and operator comparison.
Example:
export const withShoppingCartContext = Component => props => {
const { cart, removeItem, updateCart } = useContext(CartContext);
return <Component {...props} {...{ cart, removeItem, updateCart }} />;
}
const ShoppingCartQuantity = ({ cart, productId, updateCart }) => {
const qty = cart.find(item => item.id === productId)?.qty ?? 0;
return (
<div>
<span>
<span
className={`${styles.op} ${styles.decrementBtn}`}
onClick={() => updateCart(productId, -1)}
>
-
</span>
<span className={styles.qty}>{qty}</span>
<span
className={`${styles.op} ${styles.incrementBtn}`}
onClick={() => updateCart(productId, 1)}
>
+
</span>
</span>
</div>
)
}
export default ShoppingCartQuantity;
In places in your app where ShoppingCartQuantity component is used within the CartContextProvider decorate it with the withShoppingCartContext HOC and use normally.
ShoppingCart
import ShoppingCartQuantityBase, {
withShoppingCartContext
} from "../../components/layout/shoppingCart/ShoppingCartQuantity";
const ShoppingCartQuantity = withShoppingCartContext(ShoppingCartQuantityBase);
const ShoppingCart = (props) => {
...
return (
...
<ShoppingCartQuantity productId={....} />
...
);
};
In places where ShoppingCartQuantity component is used outside the context, like in the sweet modal, access the context within the React code and pass in the context values and callbacks.
...
import ShoppingCartQuantity from "../../components/layout/shoppingCart/ShoppingCartQuantity";
...
function Homepage() {
...
const { cart, updateCart } = useContext(CartContext);
const productModal = withReactContent(Swal);
...
function showProductPopupHandler(productId) {
ProductService.get(productId)
.then((response) => {
const product = response.data;
return productModal.fire({
html:
<div>
...
<div>
<FavoriteBtn productId={product.AMP_GUID}/>
<ShoppingCartQuantity
productId={product.AMP_GUID}
{...{ cart, updateCart }}
/>
</div>
...
</div>,
showConfirmButton: false,
showCloseButton: true
});
});
}
return (...);
}
export default Homepage;
Additional Issues
Your context provider is mutating state when updating quantities. When updating nested state you should still create a shallow copy of the array elements that are being updated.
Example:
const CartContextProvider = (props) => {
...
const updateCart = (productId, amount) => {
// only update if item in cart
if (cart.some(item => item.id === productId)) {
// use functional state update to update from previous state
// cart.map creates shallow copy of previous state
setCart(cart => cart.map(item => item.id === productId
? {
...item, // copy item being updated into new object reference
qty: Math.max(item.qty + amount, 1), // minimum quantity is 1
}
: item
));
}
}
const removeItem = (id) => {
setCart(cart => cart.filter(item => item.id !== id));
};
return (
<CartContext.Provider value={{ cart, updateCart, removeItem }}>
{props.children}
</CartContext.Provider>
);
};
You did't show where you are using the ShoppingCart component or the ShoppingCartQuantity component.
Anyway, when you declare a route, you must pass the component, not the root element. So, this line:
<Route exact path="/" element={<Homepage/>}/>
must be
<Route exact path="/" component={Homepage}/>
So I've created this array of objects and I would like to add some of them to favorites page. I thought I'd create onClick boolean change to each element and once value true object would be added favorites page. First of all, I am not sure if that's the right way to do it and secondly, now I am struggling with the fact that value of boolean in each element in the list is changing with that click instead of desired one. Probably I bit more than I could chew hence I am asking for some guidance.
MeetupList file
import { useState } from 'react'
import classes from './MeetupList.module.css'
import { IState as Props } from '../../pages/AllMeetups'
import Card from '../ui/Card'
interface IProps {
meetups: Props['meetupsy']
}
const MeetupList: React.FC<IProps> = ({meetups}) => {
const [toggle, setToggle] = useState(false)
const toggler = () => {
toggle ? setToggle(false): setToggle(true)
}
const renderList = ():JSX.Element[] => {
return meetups.map((meetup) => {
return(
<Card key={meetup.id}>
<li className={classes.list}>
<div className={classes.image}>
<img src={meetup.image} alt={meetup.title} />
</div>
<div className={classes.listheader}>
<h3>{meetup.title}</h3>
<address>{meetup.address}</address>
<p>{meetup.description}</p>
</div>
<div className={classes.actions}>
<button onClick={toggler}>{toggle ? <span>To Favorite</span>:<span>Not Favorite</span>}</button>
</div>
</li>
</Card>
)
})
}
return (
<ul className={classes.render}>
{renderList()}
</ul>
)
}
export default MeetupList;
All Meetups Page
import React, {useState, useEffect} from 'react'
import MeetupList from '../components/meetups/MeetupList'
import NewMeetupForm from '../components/meetups/NewMeetupForm'
import Popup from '../components/ui/Popup'
//import classes from './AllMeetups.module.css'
import './AllMeetups.css'
import { FontAwesomeIcon } from '#fortawesome/react-fontawesome'
import { faCalendarPlus } from '#fortawesome/free-regular-svg-icons'
export interface IState {
meetupsy: {
id: number
image: string
title: string
address: string
description: string
}[]
}
const AllMeetups = () => {
const [meetups, setMeetups] = useState<IState['meetupsy']>([
{
id: 123,
image: 'https://i.imgur.com/i4WpOUM.jpg',
title: 'Car Meetup',
address: 'Baker Street 14',
description: 'EUDM Meetup'
},
{
id: 1233,
image: 'https://cdn.pixabay.com/photo/2017/12/09/08/18/pizza-3007395__480.jpg',
title: 'Cooking Show',
address: 'Downtown 14',
description: 'Pizza Cooking Show'
}
])
useEffect(() => {
const data = window.localStorage.getItem('MEETUP_LIST');
if (data !== null) setMeetups(JSON.parse(data))
},[])
useEffect(() => {
window.localStorage.setItem('MEETUP_LIST', JSON.stringify(meetups))
}, [meetups])
const [buttonPopup, setButtonPopup] = useState(false);
return (
<section>
<h1 className='oke'>All Current Meetups</h1>
<MeetupList meetups={meetups} />
<button className='open' onClick={() => setButtonPopup(true)}><FontAwesomeIcon icon={faCalendarPlus} /></button>
<Popup trigger={buttonPopup} setTrigger={setButtonPopup}>
<NewMeetupForm meetups={meetups} setMeetups={setMeetups} />
</Popup>
</section>
)
}
export default AllMeetups
I have a demo here
Its a simple cart app where I'm listing products and then adding them to a cart components
It uses context for the app state.
I'd now like to be able to remove items from the cart but I'm struggling to get this to work.
Do I need to add a DeleteCartContext.Provider
import React, { useContext } from "react";
import { CartContext } from "./context";
import { IProduct } from "./interface";
const Cart = () => {
const items = useContext(CartContext);
const handleClick = (
e: React.MouseEvent<HTMLInputElement, MouseEvent>,
item: IProduct
) => {
e.preventDefault();
};
const cartItems = items.map((item, index) => (
<div>
<span key={index}>{`${item.name}: £${item.price}`}</span>
<input type="button" value="-" onClick={e => handleClick(e, item)} />
</div>
));
return (
<div>
<h2>Cart</h2>
{cartItems}
</div>
);
};
export default Cart;
There are many ways to control Items in Context so I tried to answer as similar to your structure, here is how you do that:
import React, { useContext } from "react";
import { CartContext, RemoveCartContext } from "./context";
import { IProduct } from "./interface";
const Cart = () => {
const items = useContext(CartContext);
const removeItem = useContext(RemoveCartContext);
const handleClick = (
e: React.MouseEvent<HTMLInputElement, MouseEvent>,
item: IProduct
) => {
e.preventDefault();
};
const cartItems = items.map((item, index) => (
<div>
<span key={index}>{`${item.name}: £${item.price}`}</span>
<input type="button" value="+" onClick={e => handleClick(e, item)} />
<input type="button" value="-" onClick={e => removeItem(item)} />
</div>
));
return (
<div>
<h2>Cart</h2>
{cartItems}
</div>
);
};
export default Cart;
import React, { createContext, useCallback, useRef, useState } from "react";
export const CartContext = createContext([]);
export const AddCartContext = createContext(item => {});
export const RemoveCartContext = createContext(item => {});
export const CartProvider = ({ children }) => {
const [items, setItems] = useState([]);
const itemsRef = useRef(items);
itemsRef.current = items;
return (
<AddCartContext.Provider
value={useCallback(item => {
setItems([...itemsRef.current, item]);
}, [])}
>
<RemoveCartContext.Provider
value={useCallback(item => {
const newItems = itemsRef.current.filter(
_item => _item.id !== item.id
);
setItems(newItems);
}, [])}
>
<CartContext.Provider value={items}>{children}</CartContext.Provider>
</RemoveCartContext.Provider>
</AddCartContext.Provider>
);
};
and here is a working demo: https://stackblitz.com/edit/react-ts-cart-context-mt-ytfwfv?file=context.tsx
I have a pure React-Redux application and it is working as expected.
The App.js
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import { Router, Route, Switch, Redirect } from "react-router-dom";
import history from "../history";
import LandingPage from "./home/LandingPage";
import { displayModules } from "../actions";
import Cart from "./home/Cart";
const App = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(displayModules());
}, [dispatch]);
return (
<Router history={history}>
<Switch>
<Route path="/" exact component={LandingPage}></Route>
<Route path="/cart" exact component={Cart}></Route>
<Route render={() => <Redirect to="/" />} />
</Switch>
</Router>
);
};
export default App;
The LandingPage has a nested component called Tile.
import React from "react";
import { useSelector, useDispatch } from "react-redux";
import Tile from "../common/Tile";
import { addItemToCart, displayCartContents } from "../../actions";
import "./LandingPage.css";
const LandingPage = () => {
const modules = useSelector(state => state.data.modules);
const cart = useSelector(state => state.data.cart);
const dispatch = useDispatch();
const addToCart = item => {
dispatch(addItemToCart(item));
};
return (
<div className="app">
<div className="header">
<div className="text">Insurance modules</div>
<i
className="shopping cart icon"
onClick={() => {
dispatch(displayCartContents());
}}
>
<span className="badge">{cart.length}</span>
</i>
</div>
<div className="body">
{modules.map(module => (
<Tile key={module.id} module={module} addToCart={addToCart}></Tile>
))}
</div>
</div>
);
};
export default LandingPage;
Tile.js has a button which I want to test.
import React, { useState } from "react";
import "./Tile.css";
const Tile = props => {
const { module, addToCart } = props;
const [coverage, setCoverage] = useState(parseInt(module.coverageMax - module.coverageMin) / 2);
const [price, setPrice] = useState((coverage * module.risk) / 100);
return (
<div className="tile">
<div className="tile-description">
<div>
<i className={`${module.icon} icon`}></i>
</div>
<div className="tile-name">{module.name}</div>
<div className="tile-risk">Risk(%): {module.risk}</div>
</div>
<div className="tile-footer">
<div className="tile-range">
<div className="field-label">
Select Coverage: <span className="coverage-display">{coverage}</span>
</div>
<div className="slidecontainer">
<span className="slider-step">{module.coverageMin}</span>
<input
type="range"
min={module.coverageMin}
max={module.coverageMax}
value={coverage}
className="slider"
onChange={e => {
setCoverage(e.target.value);
setPrice((e.target.value * module.risk) / 100);
}}
></input>
<span className="slider-step">{module.coverageMax}</span>
</div>
</div>
<div>
PRICE at this Coverage:<span className="tile-price">{price}</span>
</div>
<button
className="tile-button"
onClick={() => {
addToCart({
id: module.id,
name: module.name,
coverage: coverage,
price: price,
timeStamp: Math.ceil(new Date().getTime() * Math.random() * Math.random())
});
}}
>
Add module to cart
</button>
</div>
</div>
);
};
export default Tile;
App.test.js works fine and I am able to find the nested Landing Page div by className prop.
import React from "react";
import configureStore from "redux-mock-store";
import { Provider } from "react-redux";
import renderer from "react-test-renderer";
import App from "../components/App";
import history from "../history";
import { displayModules } from "../actions";
import { DISPLAY_MODULES } from "../actions/types";
const mockStore = configureStore([]);
describe("App Component test", () => {
let store = {};
let wrappedComponent = {};
const expectedActions = {
type: DISPLAY_MODULES,
payload: [
{
id: 0,
icon: "bicycle",
name: "Bike",
coverageMin: 0,
coverageMax: 3000,
risk: 30
},
{
id: 1,
icon: "gem",
name: "Jewelry",
coverageMin: 500,
coverageMax: 10000,
risk: 5
},
{
id: 2,
icon: "microchip",
name: "Electronics",
coverageMin: 500,
coverageMax: 6000,
risk: 35
},
{
id: 3,
icon: "football ball",
name: "Sports Equipment",
coverageMin: 0,
coverageMax: 20000,
risk: 30
}
]
};
beforeEach(() => {
store = mockStore({
data: {
modules: [],
cart: [],
total: 0
}
});
store.dispatch = jest.fn(displayModules);
wrappedComponent = renderer.create(
<Provider store={store}>
<App />
</Provider>
);
});
it("should render with given state from Redux store", () => {
expect(wrappedComponent.toJSON()).toMatchSnapshot();
});
it("should have an app from Landing Page", () => {
expect(wrappedComponent.root.findByProps({ className: "app" })).toBeDefined();
});
it("should show landing page for default route", () => {
*debugger;
expect(wrappedComponent.root.findByProps({ className: "shopping cart icon" })).toBeDefined();*
});
it("should show cart page for /cart route", () => {
history.push("/cart");
expect(wrappedComponent.root.findByProps({ className: "backward icon" })).toBeDefined();
});
it("should redirect to landing page for unmatched 404 routes", () => {
history.push("/someRandomRoute");
expect(wrappedComponent.root.findByProps({ className: "shopping cart icon" })).toBeDefined();
});
it("should dispatch displayModules action on app mount", async () => {
const actualAction = await store.dispatch();
expect(actualAction).toEqual(expectedActions);
});
});
But If you see the test debugger
The children of div with className: body has no children.
That is why it is not able to find the Tile component.
Can you suggest why the children are null for the body?
I have seen this before, even i tried with Enzyme i faced this issue.
Since it is a Redux wrapped component the , i cant directly create the Landing page or Tile component for testing.
How to test the nested items?
You are providing an empty array to modules inside redux state:
store = mockStore({
data: {
modules: [], // your modules is empty so no tiles will render
cart: [],
total: 0
}
});
Another issue is that you mock store.dispatch so it no longer changes the redux store even if some action is dispatched:
store.dispatch = jest.fn(displayModules);
If you want to test that an action was dispatched you can use:
const actions = store.getActions()
Which will give you all actions which were dispatched.
If you want to test how your app renders based on your store data you can either:
Setup the store in the test:
const existingModules = [ ... ]; // list of modules
store = mockStore({
data: {
modules: existingModules,
cart: [],
total: 0
}
});
You can mock useSelector in your test:
const existingModules = [ ... ]; // list of modules
const spy = jest.spyOn(redux, 'useSelector')
spy.mockReturnValue(existingModules)