3

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
Drew Reese
  • 165,259
  • 14
  • 153
  • 181
stex
  • 33
  • 1
  • 5
  • `AddToCart` and `Navbar` seem to be working with different contexts. Can you add all the relevant code into your question? Can you share these contexts and the reducer hooks, etc...? Updating the context value should be sufficient enough to trigger any context consumer to rerender with the latest context value. – Drew Reese Jan 14 '22 at 17:03
  • 1
    I think I see your bigger issue.... you are memoizing the initial state and never updating the context value. This explains why your state isn't "updating" or rather, why the consumers don't see the updates, and why mutating the state leaks them out. See my updated answer. If you've still issue from here then please try to fork your code into a *running* codesandbox that we can inspect and debug live. – Drew Reese Jan 14 '22 at 21:15
  • Had you a chance to review updated answers? – Drew Reese Jan 17 '22 at 20:44
  • I switched to redux-toolikt few days ago, but your solution works like charm on a new project which I created only for testing it. Eslint requires to memoize the initial state. It works, but still don't understand what's happening 'behind the scene' with useMemo. – stex Jan 20 '22 at 21:37
  • 1
    Yes, RTK is a superior improvement over the legacy react-redux. `useMemo` is simply memoizing a value, with the dependency array being the references you want to re-memoize a value on. This is usually to provide a stable reference to a value and not trigger unnecessary rerenders. In your case you used an empty dependency array, so the memoized value was computed on the initial render and never updated. RTK is great because it allows you to write mutable reducers and it handles the immutability concerns behind the scenes, the result is your reducer code reads more logically. – Drew Reese Jan 20 '22 at 21:46
  • 1
    Now I get it. I was putting console.logs literally everywhere in reducer function to figure out what the problem is, and it worked fine. I was shocked when console.logs returned an updated state. Realy tought that reducer function was the problem. The state updated normally, but useMemo was making troubles. RTK is far simpler than legacy redux, and maybe I'm wrong but useContext with useReducer seems to been even simpler than RTK. Thanks a lot! It was very helpfull. – stex Jan 20 '22 at 22:27

4 Answers4

2

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
  }
}
Prajwal Kulkarni
  • 1,480
  • 13
  • 22
  • 1
    `const newState = [...state]` is only a shallow copy, FYI. – Drew Reese Jan 14 '22 at 21:01
  • But, it doesn't point to the same location in the memory, right? Does it? One simple evidence to support this is checking their equality. – Prajwal Kulkarni Jan 15 '22 at 06:15
  • It's a shallow copy, the array is a new reference, yes, but all the elements are copy by reference, meaning they still refer to all the elements in the original array. In other words, it's **not** a *deep copy*. – Drew Reese Jan 15 '22 at 06:23
1

It seems you are mutating the state object in your reducer function. You first save a reference to the state with const newState = state, then mutate that reference with each newState[state.length] = ....., and then return the same state reference for the next state with return newState. The next state object is never a new object reference.

Consider the following that uses various array methods to operate over the state array and return new array references:

export const reducer = (state, action) => {
  // returns -1 if product doesn't exist
  const indexOfProductInCart = state.findIndex(
    (item) => item.productName === action.productName
  );

  const newState = state.slice(); // <-- create new array reference

  switch (action.type) {
    case 'increment': {
      if (indexOfProductInCart === -1) {
        // Not in cart, append with initial count of 1
        return newState.concat({
          productName: action.productName,
          count: 1,
        });
      }
      // In cart, increment count by 1
      newState[indexOfProductInCart] = {
        ...newState[indexOfProductInCart]
        count: newState[indexOfProductInCart].count + 1,
      }
      return newState;
    }

    case 'decrement': {
      if (indexOfProductInCart === -1) {
        // Not in cart, append with initial count of 1
        return newState.concat({
          productName: action.productName,
          count: 1,
        });
      }
      // In cart, decrement count by 1, to minimum of 1, then remove
      if (newState[indexOfProductInCart].count === 1) {
        return state.filter((item, index) => index !== indexOfProductInCart);
      }
      newState[indexOfProductInCart] = {
        ...newState[indexOfProductInCart]
        count: Math.max(0, newState[indexOfProductInCart].count - 1),
      }
      return newState;
    }

    case 'addToCart': {
      if (indexOfProductInCart === -1) {
        // Not in cart, append with initial action count
        return newState.concat({
          productName: action.productName,
          count: action.count,
        });
      }
      // Already in cart, increment count by 1
      newState[indexOfProductInCart] = {
        ...newState[indexOfProductInCart]
        count: newState[indexOfProductInCart].count + 1,
      }
      return newState;
    }

    case 'remove':
      return state.filter((item, index) => index !== indexOfProductInCart);

    default: return state
  }
}

itemsSum in Navbar should now see the state updates from the context.

const itemsSum = itemCounter.state
  .map((item) => item.count)
  .reduce((prev, curr) => prev + curr, 0);

It also appears you've memoized the state value in a useMemo hook with an empty dependency array. This means the counter value passed to StoreContext.Provider never updates.

function App() {
  const initialState = [{ productName: '', count: 0 }];
  const [state, dispatch] = useReducer(reducer, initialState);

  const counter = useMemo(() => ({ state, dispatch }), []); // <-- memoized the initial state value!!!

  return (
    <div className='app'>
      <StoreContext.Provider value={store}> // <-- passing memoized state
        ...
      </StoreContext.Provider>
    </div>
  )
}

Either add state to the dependency array

const counter = useMemo(() => ({ state, dispatch }), [state]);

Or don't memoize it at all and pass state and dispatch to the context value

<StoreContext.Provider value={{ state, dispatch }}>
  ...
</StoreContext.Provider>
Drew Reese
  • 165,259
  • 14
  • 153
  • 181
0

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
stex
  • 33
  • 1
  • 5
  • Since I don't think you were trying to answer your own question here and only adding information, you may want to move these details into your question and delete this answer. – Drew Reese Jan 14 '22 at 17:54
0

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 ] }

stex
  • 33
  • 1
  • 5
  • 1
    Again, this should be a comment either on your post or in response to an answer. You should ***never*** mutate state in React, it doesn't matter if you are using `useReducer`, `useState`, `state` in class components, redux, etc.... you should *always* exercise the immutable update pattern. IMO your `initialState` should be an empty array, i.e. `const initialState = []` as you've not added any items to the cart just yet. – Drew Reese Jan 14 '22 at 20:58