Search code examples
javascriptreactjsreact-hooksreact-state-managementuse-reducer

useReducer: dispatch action, show state in other component and update state when action is dispatched


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

Solution

  • 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>