Persist localStorage with useReducer
Asked Answered
V

3

14

I have a mini shopping cart application that uses useState. I now want to refactor the application's state to be managed by useReducer and continue to persist data with localStorage.

I'm having trouble figuring out how to refactor, with the many moving pieces involved. How do I go about refactoring the logic within addToCartHandler to be instead used inside the ADD_TO_CART case? From there, I believe I'd be able to figure out the pattern for the other cases in the cartReducer. Thank you.

https://codesandbox.io/s/goofy-water-pb903?file=/src/App.js

Vachel answered 27/10, 2020 at 1:11 Comment(0)
R
35

Use Context API to manage cart state

I would start by isolating your cart state and persistence to local storage to a react context provider. The context can provide the cart state and action dispatcher to the rest of the app, as well as persist the state to localStorage when the state updates using an effect. This decouples all of the state management from the app, the app need only consume the context to access the cart state and dispatch actions to update it.

import React, { createContext, useEffect, useReducer } from "react";
import { cartReducer, initializer } from "../cartReducer";

export const CartContext = createContext();

export const CartProvider = ({ children }) => {
  const [cart, dispatch] = useReducer(cartReducer, [], initializer);

  useEffect(() => {
    localStorage.setItem("localCart", JSON.stringify(cart));
  }, [cart]);

  return (
    <CartContext.Provider
      value={{
        cart,
        dispatch
      }}
    >
      {children}
    </CartContext.Provider>
  );
};

Wrap the app in the CartProvider in index.js

<CartProvider>
  <App />
</CartProvider>

Edit persist-localstorage-with-usereducer

Round out the rest of the app

In cartReducer refine the reducer, and export the initializer function and action creators.

const initialState = [];

export const initializer = (initialValue = initialState) =>
  JSON.parse(localStorage.getItem("localCart")) || initialValue;

export const cartReducer = (state, action) => {
  switch (action.type) {
    case "ADD_TO_CART":
      return state.find((item) => item.name === action.item.name)
        ? state.map((item) =>
            item.name === action.item.name
              ? {
                  ...item,
                  quantity: item.quantity + 1
                }
              : item
          )
        : [...state, { ...action.item, quantity: 1 }];

    case "REMOVE_FROM_CART":
      return state.filter((item) => item.name !== action.item.name);

    case "DECREMENT_QUANTITY":
      // if quantity is 1 remove from cart, otherwise decrement quantity
      return state.find((item) => item.name === action.item.name)?.quantity ===
        1
        ? state.filter((item) => item.name !== action.item.name)
        : state.map((item) =>
            item.name === action.item.name
              ? {
                  ...item,
                  quantity: item.quantity - 1
                }
              : item
          );

    case "CLEAR_CART":
      return initialState;

    default:
      return state;
  }
};

export const addToCart = (item) => ({
  type: "ADD_TO_CART",
  item
});

export const decrementItemQuantity = (item) => ({
  type: "DECREMENT_QUANTITY",
  item
});

export const removeFromCart = (item) => ({
  type: "REMOVE_FROM_CART",
  item
});

export const clearCart = () => ({
  type: "CLEAR_CART"
});

In Product.js get the cart context via a useContext hook and dispatch an addToCart action

import React, { useContext, useState } from "react";
import { CartContext } from "../CartProvider";
import { addToCart } from "../cartReducer";

const Item = () => {
  const { dispatch } = useContext(CartContext);

  ...

  const addToCartHandler = (product) => {
    dispatch(addToCart(product));
  };

  ...

  return (
    ...
  );
};

CartItem.js get and use the cart context to dispatch actions to decrement quantity or remove item.

import React, { useContext } from "react";
import { CartContext } from "../CartProvider";
import { decrementItemQuantity, removeFromCart } from "../cartReducer";

const CartItem = () => {
  const { cart, dispatch } = useContext(CartContext);

  const removeFromCartHandler = (itemToRemove) =>
    dispatch(removeFromCart(itemToRemove));

  const decrementQuantity = (item) => dispatch(decrementItemQuantity(item));

  return (
    <>
      {cart.map((item, idx) => (
        <div className="cartItem" key={idx}>
          <h3>{item.name}</h3>
          <h5>
            Quantity: {item.quantity}{" "}
            <span>
              <button type="button" onClick={() => decrementQuantity(item)}>
                <i>Decrement</i>
              </button>
            </span>
          </h5>
          <h5>Cost: {item.cost} </h5>
          <button onClick={() => removeFromCartHandler(item)}>Remove</button>
        </div>
      ))}
    </>
  );
};

App.js get both the cart state and dispatcher via context hook, and update the total items and price logic to account for item quantities.

import { CartContext } from "./CartProvider";
import { clearCart } from "./cartReducer";

export default function App() {
  const { cart, dispatch } = useContext(CartContext);

  const clearCartHandler = () => {
    dispatch(clearCart());
  };

  const { items, total } = cart.reduce(
    ({ items, total }, { cost, quantity }) => ({
      items: items + quantity,
      total: total + quantity * cost
    }),
    { items: 0, total: 0 }
  );

  return (
    <div className="App">
      <h1>Emoji Store</h1>
      <div className="products">
        <Product />
      </div>
      <div className="cart">
        <CartItem />
      </div>
      <h3>
        Items in Cart: {items} | Total Cost: ${total.toFixed(2)}
      </h3>
      <button onClick={clearCartHandler}>Clear Cart</button>
    </div>
  );
}
Ragouzis answered 27/10, 2020 at 4:9 Comment(3)
Wow, many thanks for taking the application a step further! Grateful for your continued assistance.Vachel
@Vachel Yes, you're welcome. I was about 75% into my proposed changes when Zacharey posted their answer and I wanted to ensure I was bringing something enough different to the table to be worth value answering.Ragouzis
I've accepted your answer instead because it is the more optimal solution and that you've helped me out before!Vachel
M
8

Here's my working of it. I added all the cases for the cartReducer because I was having fun with it.

In case you want to work through it yourself, here's the first case with the setup to use the localStorage to keep the item values.

The overview of what I'm doing is: Use the switch case to set up the new state in the reducer, and then set the localStorage state to the new value every time the cart changes via an effect.

The logic in the product is just replaced with a simple action dispatch. As the logic is instead in the reducer. It's likely you could simplify the logic in the ADD_TO_CART case, but this handles everything and in an immutable way. Using something like immer would simplify the logic by a decent bit.

const storageKey = "localCart";
const cartReducer = (state, action) => {
  switch (action.type) {
    case "ADD_TO_CART": {
      const product = action.payload;
      let index = state.findIndex((item) => product.name === item.name);
      if (index >= 0) {
        const newState = [...state];
        newState.splice(index, 1, {
          ...state[index],
          quantity: state[index].quantity + 1
        });
        return newState
      } else {
        return [...state, { ...product, quantity: 1 }];
      }
    }
    default:
      throw new Error();
  }
};

Use in the App component:

  const [cart, cartDispatch] = useReducer(
    cartReducer,
    [],
    // So we only have to pull from localStorage one time - Less file IO
    (initial) => JSON.parse(localStorage.getItem(storageKey)) || initial
  );
  useEffect(() => {
    // This is a side-effect and belongs in an effect
    localStorage.setItem(storageKey, JSON.stringify(cart));
  }, [cart]);

Use in the Product component:

  const addToCartHandler = (product) => {
    dispatch({ type: "ADD_TO_CART", payload: product });
  };

Full working CodeSandbox

Mimi answered 27/10, 2020 at 2:29 Comment(1)
Thank you very much for explaining the steps of the refactor! I appreciate the commented code as well to help my understanding.Vachel
S
1

Here's a simple useSessionStorage hook that I've created:

import { Dispatch, Reducer, useCallback, useEffect, useReducer } from 'react';

const initializer =
  (key: string) =>
  <T>(initial: T) => {
    const stored = sessionStorage.getItem(key);
    if (!stored) return initial;
    return JSON.parse(stored);
  };

export const useSessionReducer = <T extends object, A>(
  reducer: Reducer<T, A>,
  initialState: T,
  key: string,
): [T, Dispatch<A>, VoidFunction] => {
  const [state, dispatch] = useReducer(reducer, initialState, initializer(key));
  const clearValue = useCallback(() => sessionStorage.removeItem(key), [key]);

  useEffect(() => {
    sessionStorage.setItem(key, JSON.stringify(state));
  }, [state]);

  return [state, dispatch, clearValue];
};

And this is how you use it:

const [state, dispatch, clearSaved] = useSessionReducer(cartReducer, initialState, 'cart');

P.S. Swapping sessionStorage with localStorage will not change how the hook works.

Saum answered 15/5, 2023 at 20:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.