DEV Community

Cover image for Using React Hooks to Make an RPG Shop - Part 2
Jess
Jess

Posted on

Using React Hooks to Make an RPG Shop - Part 2

Now that I've touched on how some hooks work in the previous post I'll explain my actual project. I had it ready to go before writing that post, but after I wrote it I realized I wasn't even taking full advantage of the hooks I was using. I guess there really is something to writing these posts that helps to develop a better understanding of these technologies afterall. 😆

As I was refactoring the project I somehow completely broke it. I would add an item to the cart and it would work fine, but if I added another of the same type it would add 2 more instead of 1. After some googling I determined the issue to be with <React.StrictMode> which is wrapped around <App /> in index.js.

The purpose of StrictMode is to highlight potential problems and detect unexpected side effects. It works in development mode only, and causes your components to render twice. When I remove StrictMode from my app it works as intended so clearly this is the culprit. I'm still unsure of why I'm getting the unintended side effect of it adding a quantity of 2 to the item the second time, yet not the first time. I'm going to have to continue debugging this, but in the meantime I removed StrictMode and it works. 😅

App Organization

In the src folder I have all of my components separated in their own folders inside a components folder. Each folder contains a .js and .css file for the corresponding component, as seen in the Store folder in the image above. In the reducers folder, there are files for each useReducer in my app. I'm using two: One handles adding, updating, and removing items from the cart, and the other handles opening and closing the modal as well as keeping track of the item that was clicked. The helpers folder contains a file called constants, which holds the const objects I'm using, and cartHelpers holds the logic for doing all the cart editing and doing the math for the cart total.

How it Works

I decided not use App.js for my main logic because I have a footer on the page, so App just looks like this:

const App = () => (
  <div className="App">
    <Store />
    <Footer />
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Store.js is where my main logic is. In hindsight this name might be confusing, because the word 'store' is associated with reducers as a container for state, but in this instance it's my item shop. I guess I should've just called it Shop. 🤦🏻‍♀️ I might go back and change that...

Store holds the two reducers mentioned earlier:

const [cart, dispatchCart] = useReducer(cartReducer, []);

const [itemClicked, dispatchItemClicked] = useReducer(itemClickedReducer, { isModalVisible: false, modalType: null, item: null });
Enter fullscreen mode Exit fullscreen mode

cart is initialized to an empty array, and itemClicked is initialized as an object with a few properties: isModalVisible controls when the add/remove item modal is displayed, modalType controls whether it's for adding or removing an item, and item stores the item object when an item in clicked in either Inventory or Cart.

I considered separating the modal stuff from the item clicked, but the modal needs to know about the item to display its info, and when the form in the modal is submitted that's when dispatchCart runs to either add or remove that item, so to me it makes sense to keep them grouped together.

There are a few functions inside Store:

handleSubmitItem is passed to HowManyModal (the modal with the form to add x amount of an item to the cart) and receives qty once the modal form is submitted. Since handleSubmitItem is inside Store it knows about the itemClicked. It checks if the modalType is MODAL.ADD or MODAL.REMOVE and sets a const fn to the appropriate function. fn is run with the item and quantity.

MODAL.ADD and MODAL.REMOVE are just constants to make it easier to read and safer than writing strings that might be typed incorrectly. My actions to send to the dispatcher are also stored as constants.

// constants.js
export const ACTIONS = {
  SET: 'set',
  CLEAR: 'clear',
  ADD_TO_CART: 'add-to-cart',
  REMOVE_FROM_CART: 'remove-from-cart',
  UPDATE_QUANTITY: 'update-quantity'
}

export const MODAL = {
  ADD: 'add',
  REMOVE: 'remove'
}
Enter fullscreen mode Exit fullscreen mode
// Store.js
const Store = () => {
  // reducers, other functions...

  const handleSubmitItem = (qty) => {
    const fn = itemClicked.modalType === MODAL.ADD ?
      handleAddToCart : handleRemoveFromCart;

    fn(itemClicked.item, qty);
  };

  // ... etc
}
Enter fullscreen mode Exit fullscreen mode

If adding, handleAddToCart is the function that's run. It checks to see if the item already exists in the cart. If so, dispatchCart is run with the type ACTIONS.UPDATE_QUANTITY, otherwise it's run with type ACTIONS.ADD_TO_CART.

// Store.js
const Store = () => {
  // reducers, other functions...

  const handleAddToCart = (item, qty) => {
    const itemExists = cart.find(i => i.name === item);

    const type = itemExists ? ACTIONS.UPDATE_QUANTITY : ACTIONS.ADD_TO_CART;

    dispatchCart({ payload: { item, qty }, type });
  }

  // ... etc
}
Enter fullscreen mode Exit fullscreen mode

If removing, a similar thing happens in handleRemoveFromCart. If the item's quantity property is equal to qty, dispatchCart is run with type ACTIONS.REMOVE_FROM_CART, otherwise it's run with type ACTIONS.UPDATE_QUANTITY and the qty property in the payload is set to -qty so that the updateQuantity function will add the negative amount to the item's quantity, which actually subtracts it.

// Store.js
const Store = () => {
  // reducers, other functions...

  const handleRemoveFromCart = (item, qty) => {
    const removeAll = item.quantity === qty;

    removeAll ?
      dispatchCart({ type: ACTIONS.REMOVE_FROM_CART, payload: { item } })
      :
      dispatchCart({ type: ACTIONS.UPDATE_QUANTITY, payload: { qty: -qty, item } });
  }

  // ... etc
}
Enter fullscreen mode Exit fullscreen mode
// cartHelpers.js

export const updateQuantity = (cart, item, quantity) => (
  cart.map(i => (
    i.name === item.name ?
      { ...i, quantity: i.quantity += quantity } : i
  ))
);
Enter fullscreen mode Exit fullscreen mode

The HowManyModal component is the modal that pops up when an item is clicked. It uses the useState hook to keep track of the item quantity the user wants to add or remove.

const [howMany, setHowMany] = useState(1);
Enter fullscreen mode Exit fullscreen mode

A form with a number input has a value set to howMany. howMany is initialized as 1 so that a quantity of 1 is first displayed in the modal, and the user can adjust from there.

add to cart from inventory


Add to Cart from Inventory

If the modalType is MODAL.REMOVE the max number that can be input is the max amount the user has of that item in their cart, otherwise it maxes out at 99.

<input
  type="number"
  id="how-many"
  min="1"
  max={`${modalType === MODAL.REMOVE ? itemClicked.quantity : 99}`}
  value={howMany}
  onChange={handleOnChange}
/>
Enter fullscreen mode Exit fullscreen mode

remove item from cart


Remove Item from Cart

As mentioned previously, when the "Add to Cart"/"Remove from Cart" button is clicked, handleSubmitItem runs and dispatches the appropriate reducer based on the modal type. Another function runs next: clearItemClicked which dispatches dispatchItemClicked with the type ACTIONS.CLEAR. This just sets isModalVisible back to false and modalType and item to null. Alternatively I could have just pass the dispatch function directly to the modal instead of passing clearItemClicked down, but I think I did it this way when I was considering separating the itemClicked from the modal stuff.

That's pretty much the bulk of how it works. The rest of the functions are presentational and broken down to display the items in their containers.

The code can be viewed on github if you'd like to check it out.

Try the demo here


Further Reading / References

Top comments (2)

Collapse
 
chron profile image
Paul Prestidge

About your reducer running twice – I think that's normal, but it shouldn't be a problem because reducers are supposed to be pure functions (with no side effects), which means running it twice should always produce the same result.

It looks like you are accidentally mutating the existing state in cartHelpers.js:

export const updateQuantity = (cart, item, quantity) => (
  cart.map(i => (
    i.name === item.name ?
      { ...i, quantity: i.quantity += quantity } : i
  ))
);
Enter fullscreen mode Exit fullscreen mode

I think that += should actually just be + and then you won't have any problems.

I also noticed this line in Store.js which I think will actually never return true:

const itemExists = cart.find(i => i.name === item);
Enter fullscreen mode Exit fullscreen mode

It looks like it should be comparing i.name to item.name, although obviously the code works either way since you have written your addToCart as an add / update depending on the current cart. But maybe something to refactor :)

Collapse
 
robotspacefish profile image
Jess

ahh good catches, thanks for the second set of eyes! :)