I have a problem which I can't figure it out. I'm building an ecommerce react app and using useReducer and useContext for state management. Client opens a product, picks number of items and then click button "Add to Cart" which dispatches an action. This part is working well, and the problem starts. I don't know how to show and update in Navbar.js component a total number of products in cart. It is showing after route changes, but I want it to update when clicking Add to Cart button. I tried useEffect but it doesn't work.
initial state looks like this
const initialState = [
{
productName: '',
count: 0
}
]
AddToCart.js works good
import React, { useState, useContext } from 'react'
import { ItemCounterContext } from '../../App'
function AddToCart({ product }) {
const itemCounter = useContext(ItemCounterContext)
const [countItem, setCountItem] = useState(0)
const changeCount = (e) => {
if (e === '+') { setCountItem(countItem + 1) }
if (e === '-' && countItem > 0) { setCountItem(countItem - 1) }
}
return (
<div className='add margin-top-small'>
<div
className='add-counter'
onClick={(e) => changeCount(e.target.innerText)}
role='button'
>
-
</div>
<div className='add-counter'>{countItem}</div>
<div
className='add-counter'
onClick={(e) => changeCount(e.target.innerText)}
role='button'
>
+
</div>
<button
className='add-btn btnOrange'
onClick={() => itemCounter.dispatch({ type: 'addToCart', productName: product.name, count: countItem })}
>
Add to Cart
</button>
</div>
)
}
export default AddToCart
Navbar.js is where I have a problem
import React, { useContext } from 'react'
import { Link, useLocation } from 'react-router-dom'
import NavList from './NavList'
import { StoreContext, ItemCounterContext } from '../../App'
import Logo from '../Logo/Logo'
function Navbar() {
const store = useContext(StoreContext)
const itemCounter = useContext(ItemCounterContext)
const cartIcon = store[6].cart.desktop
const location = useLocation()
const path = location.pathname
const itemsSum = itemCounter.state
.map((item) => item.count)
.reduce((prev, curr) => prev + curr, 0)
const totalItemsInCart = (
<span className='navbar__elements-sum'>
{itemsSum}
</span>
)
return (
<div className={`navbar ${path === '/' ? 'navTransparent' : 'navBlack'}`}>
<nav className='navbar__elements'>
<Logo />
<NavList />
<Link className='link' to='/cart'>
<img className='navbar__elements-cart' src={cartIcon} alt='AUDIOPHILE CART ICON' />
{itemsSum > 0 ? totalItemsInCart : null}
</Link>
</nav>
</div>
)
}
export default Navbar
Well, ItemCounterContext is important for this problem, just ignore StoreContext, it's for images... Here is a reducer function.
export const reducer = (state, action) => {
// returns -1 if product doesn't exist
const indexOfProductInCart = state.findIndex((item) => item.productName === action.productName)
const newState = state
switch (action.type) {
case 'increment': {
if (indexOfProductInCart === -1) {
newState[state.length] = { productName: action.productName, count: state.count + 1 }
return newState
}
newState[indexOfProductInCart] = { productName: action.productName, count: state.count + 1 }
return newState
}
case 'decrement': {
if (indexOfProductInCart === -1) {
newState[state.length] = { productName: action.productName, count: state.count - 1 }
return newState
}
newState[indexOfProductInCart] = { productName: action.productName, count: state.count - 1 }
return newState
}
case 'addToCart': {
if (indexOfProductInCart === -1) {
newState[state.length] = { productName: action.productName, count: action.count }
return newState
}
newState[indexOfProductInCart] = { productName: action.productName, count: action.count }
return newState
}
case 'remove': return state.splice(indexOfProductInCart, 1)
default: return state
}
}
And here is App.js where I share state to other components
import React, { createContext, useMemo, useReducer } from 'react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Navbar from './components/Navbar/Navbar'
import Homepage from './pages/Homepage/Homepage'
import Footer from './components/Footer/Footer'
import ErrorPage from './pages/ErrorPage/ErrorPage'
import SelectedCategory from './pages/SelectedCategory/SelectedCategory'
import SingleProduct from './pages/SingleProduct/SingleProduct'
import ScrollToTop from './services/ScrollToTop'
import store from './services/data.json'
import { reducer } from './services/ItemCounter'
import './scss/main.scss'
export const StoreContext = createContext(store)
export const ItemCounterContext = createContext()
function App() {
const initialState = [{ productName: '', count: 0 }]
const [state, dispatch] = useReducer(reducer, initialState)
const counter = useMemo(() => ({ state, dispatch }), [])
return (
<div className='app'>
<StoreContext.Provider value={store}>
<ItemCounterContext.Provider value={counter}>
<Router>
<ScrollToTop />
<Navbar />
<Routes>
<Route path='/' element={<Homepage />} />
<Route path='/:selectedCategory' element={<SelectedCategory />} />
<Route path='/:selectedCategory/:singleProduct' element={<SingleProduct />} />
<Route path='*' element={<ErrorPage />} />
</Routes>
<Footer />
</Router>
</ItemCounterContext.Provider>
</StoreContext.Provider>
</div>
)
}
export default App
The problem is in your reducer, particularly where you assign the previous state to the newState to make mutations and return the updated state. In JavaScript, non-primitive are referred by address and not by value. Since your initialState which is an array happens to be a non-primitive, so when you assign a non-primitive to a new variable, this variable only points to the existing array in memory and does not create a new copy. And, in react updates are triggered/broadcasted only when a state is reconstructed (that's how React understands that there is an update) and not softly mutated. When you mutate and return newState, you are basically mutating the existing state and not causing it to reconstruct. A quick workaround for this would be to copy over your state into newState and not merely assign it. This could be done using the spread operator(...).
In your reducer function, change:
const newState = state
to
const newState = [...state]
Your reducer function should then look something like this:
export const reducer = (state, action) => {
// returns -1 if product doesn't exist
const indexOfProductInCart = state.findIndex((item) => item.productName === action.productName)
const newState = [...state] //Deep-copying the previous state
switch (action.type) {
case 'increment': {
if (indexOfProductInCart === -1) {
newState[state.length] = { productName: action.productName, count: state.count + 1 }
return newState
}
newState[indexOfProductInCart] = { productName: action.productName, count: state.count + 1 }
return newState
}
case 'decrement': {
if (indexOfProductInCart === -1) {
newState[state.length] = { productName: action.productName, count: state.count - 1 }
return newState
}
newState[indexOfProductInCart] = { productName: action.productName, count: state.count - 1 }
return newState
}
case 'addToCart': {
if (indexOfProductInCart === -1) {
newState[state.length] = { productName: action.productName, count: action.count }
return newState
}
newState[indexOfProductInCart] = { productName: action.productName, count: action.count }
return newState
}
case 'remove': return state.splice(indexOfProductInCart, 1)
default: return state
}
}
I know exactly what are you talking about, but the problem in reducer is that only mutative methods works on state. Immutable methods like .slice(), .concat() or even spread operator [...state] doesn't work and I don't know why :( I tried both of the answers, but dispatch(action) doesn't change the state. Maybe initial state is the problem, I'll try to put it like
initialState = { cart: [ productName: '', count: 0 ] }