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

Viewed 1152

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
4 Answers

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

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>

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

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

Related