DEV Community

Cover image for Build a React-Redux Shopping List App
Harley Ferguson
Harley Ferguson

Posted on • Edited on

Build a React-Redux Shopping List App

Photo by Jessica Lewis on Unsplash.

If you don't know what Redux is, then go read this before starting so that you have an understanding of the fundamentals.


The Problem

We're needing to build an application that allows users to keep track of their shopping list. Let's call it ShopDrop. ShopDrop needs to meet certain criteria:

  • Users need to be able to add an item to their shopping list
  • Users need to be able to mark an item as in their basket
  • Users need to be able to remove an item from their shopping list
  • Users need to be able to clear the entire shopping list

That's the basic functionality of what a shopping list is. Now let's look at how we meet these criteria by using Redux to manage our state.


The Product

Above is an image of how I chose to design the user interface.

You'll notice how we have a text input where users can input the shopping item. They can then click the Add button to add that item to their list. They can click the Clear button to remove all items from the list. If the user taps an item, it'll mark that item as in their basket and the colour will change to grey. If they tap the item again, it'll remove that single item from the list.

I'm not going to cover the components I built to faciliate the project because that's not the purpose of this blog. This is purely how I decided to construct my UI. You can implement it however you wish, however, the final parts of this post will demonstrate exactly how I constructed my components.


Actions

Inside the src folder of our project, create another folder called store. We'll create two files in here - actions.js and reducer.js. Go ahead and create the first so long.

// actions.js

export const actionCreators = {
  addToList: data => ({ type: "ADD_TO_LIST", payload: data }),
  addToBasket: data => ({ type: "ADD_TO_BASKET", payload: data }),
  removeItem: data => ({ type: "REMOVE_ITEM", payload: data }),
  clearItems: () => ({ type: "CLEAR_ITEMS" })
};
Enter fullscreen mode Exit fullscreen mode

This is how are action creators must look. We're following the FSA model that we discussed in the previous blog post. We need four (one for each manipulation of the store we need to perform). Notice how the first 3 all take in a payload. That's because they'll need to take in something like the value of the shopping item text or an id of the item to either mark it as in the basket or remove it from the list. The reason clearItems doesn't need any data is because all we'll need to do there is set the array in our store back to an empty array. Therefore, we don't need to pass any data through.


Add Item

Now go ahead and create reducer.js file inside of our store folder. Then let's set up our initial state which should look something like this:

const initialState = {
  items: []
}
Enter fullscreen mode Exit fullscreen mode

Now let's create our reducer and the first action we'd need to handle which is adding a new item to the item array in our store.

export default (state = initialState, action) => {
  switch (action.type) {
    case "ADD_TO_LIST":
      return {
        ...state,
        items: [
          ...state.items,
          {            
            value: action.payload,
            inBasket: false
          }
        ]
      };
    default:
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

Since we're only going to export our reducer function from this file, we can use the keywords export default and not have to provide a function name. Our reducer function then takes in the initialState and the current action that's been sent to the store.

Before we dispatch any actions to the store, the value of our store would just be the empty items array. Then as actions start coming in, that value will change to reflect those changes. Don't get confused and think that we're resetting state to the value of initialState each time an action comes into our reducer.

Our ADD_TO_LIST case might look a little confusing if you're new to Redux and immutable update patterns in JavaScript, however, it's fairly simple what's actually going on. When the action.type is of the value ADD_TO_LIST, we'll make use of the spread operator to return the current value of the state and then append a new item to the current state.items array.

This is how we immutably update the state. A summary is that we take the current state value, make our changes immutably and then return that entirely new object which is the set as the new state value.

Clear Items

You may already have an idea on how to handle the functionality for clearing the items:

case "CLEAR_ITEMS": {
      return {
        items: []
      };
    }
Enter fullscreen mode Exit fullscreen mode

Here we've added another case to our reducer and all it has to do is return the new state object with items as an empty array. That's it.

Add Item To Basket

Note: For demonstration purposes, I'm going to be making use of an the index to match our item with the same item in the array. I wouldn't normally condone using indices instead of a unique identifier but for simplicity's sake, let's go with the index.

We've looked at adding an item to the array and then clearing the whole array. Now is where we properly need to think about immutable update patterns. Adding an item to our basket means that we need to reassign the inBasket propety on that item to true.

If you go read the Redux guide to immutable update patterns, you'll see that they mention using a function to handle updating an item in an array that looks like this:

function updateObjectInArray(array, action) {
  return array.map((item, index) => {
    if (index !== action.index) {
      // This isn't the item we care about - keep it as-is
      return item
    }

    // Otherwise, this is the one we want - return an updated value
    return {
      ...item,
      ...action.item
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Let's follow how the guides tell us to do things (at least in this instance). Add the above function to your reducer.js file but outside of our reducer, however, let's make a slight change so that we're properly updating the inBasket to true. We'll do this in the last return object since that means the indices matched.

    return {
      ...item,
      inBasket: true
    }  
Enter fullscreen mode Exit fullscreen mode

This function is only going to be used by our reducer so we don't have to export it.

Our case for marking an item as in our basket would then look like this:

case "ADD_TO_BASKET":
      return {
        ...state,
        items: updateObjectInArray(state.items, action)
      };
Enter fullscreen mode Exit fullscreen mode

We call the updateObjectInArray function and provide it with our items array along with the current action that our reducer is making sense of. The updateObjectInArray function will then return to us the updated items array.

Remove An Item From The List

Again, we can reference the immutable update patterns documentation to see how they suggest remove an item from an array.

The show a couple variations but this is the simplest:

function removeItem(array, action) {
  return array.filter((item, index) => index !== action.index)
}
Enter fullscreen mode Exit fullscreen mode

Once again, let's add that function as a private function to our reducer.js file.

Our REMOVE_ITEM case will then look a little something like this:

case "REMOVE_ITEM":
      return {
        ...state,
        items: removeItemFromList(state.items, action)
      };
Enter fullscreen mode Exit fullscreen mode

Just like our previous case, we're calling off to a function which we provide an array (our items) and the current action. What's returned to use is a new items array with the relevant changes having been made.

Our entire reducer.js file should look something like this:

const initialState = {
  items: []
};

const updateObjectInArray = (array, action) => {
  return array.map((item, index) => {
    if (index !== action.payload) {
      return item;
    }

    return {
      ...item,
      inBasket: true
    };
  });
};

const removeItem = (array, action) => {
  return array.filter((item, index) => index !== action.payload);
};

export default (state = initialState, action) => {
  switch (action.type) {
    case "ADD_TO_LIST":
      return {
        ...state,
        items: [
          ...state.items,
          {            
            value: action.payload,
            inBasket: false
          }
        ]
      };
    case "ADD_TO_BASKET":
      return {
        ...state,
        items: updateObjectInArray(state.items, action)
      };
    case "REMOVE_ITEM":
      return {
        ...state,
        items: removeItem(state.items, action)
      };
    case "CLEAR_ITEMS": {
      return {
        items: []
      };
    }
    default:
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

Add Item Component

Now is the part where we would actually need to build our component that is going to dispatch our actions. For adding an item, all you'll need is an input that keeps track of the value and a button that when clicked, will dispatch an addToList action with the current value of the input. Let's save time and implement the clearing items functionality here too.

Using hooks and the react-redux library, you can import dispatch and then just wrap any of your action creators method in dispatch. Your component could end up looking something like this:

import React, { useState } from "react";
import { Button } from "react-bootstrap";
import { useDispatch } from "react-redux";
import { actionCreators } from "../../store/actions";

export default function AddItem() {
  const dispatch = useDispatch();
  const [input, setInput] = useState("");

  const handleInputChange = event => {
    return setInput(event.target.value);
  };

  const handleSubmit = () => {
    dispatch(actionCreators.addToList(input));
    setInput("");
  };

  const handleClear = () => {
    dispatch(actionCreators.clearItems());
  };

  return (
    <div>
      <input
        className="input"
        placeholder="Add item..."
        value={input}
        onChange={handleInputChange}
      />
      <Button className="button" variant="outline-dark" onClick={handleSubmit}>
        Add
      </Button>
      <Button className="button" variant="outline-dark" onClick={handleClear}>
        Clear
      </Button>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

We've setup the input and make use of the useState hook to track and clear that value. The handleInputChange simply updates that value on each JavaScript event that is emitted with each key press. We then have two buttons for our two operations. Each button has a handler method that just dispatches the relevant action (which we import from our /store/actions file).


Viewing The Shopping List

Now let's build a componentt to display our current list of items as well as provide us with an interface in which to mark the items as either in our basket or removed.

Again we'll import our action creators as well as useDispatch from the react-redux library but we'll also import useSelector from the same library. useSelector is a selector hook that allows us to get values out of the store.

import React from "react";
import { ListGroup } from "react-bootstrap";
import { useDispatch, useSelector } from "react-redux";
import { actionCreators } from "../../store/actions";

export default function ShoppingList() {
  const dispatch = useDispatch();
  const items = useSelector(state => state.items);

  const addItemToBasket = index => {
    dispatch(actionCreators.addToBasket(index));
  };

  const removeItemFromList = index => {
    dispatch(actionCreators.removeItem(index));
  };

  return (
    <ListGroup className="m-4" variant="flush">
      {items.map((item, index) => {
        return item.inBasket ? (
          <ListGroup.Item
            key={index}
            variant="dark"
            onClick={() => removeItemFromList(index)}
          >
            {item.value}
          </ListGroup.Item>
        ) : (
          <ListGroup.Item
            key={index}
            variant="danger"
            onClick={() => addItemToBasket(index)}
          >
            {item.value}
          </ListGroup.Item>
        );
      })}
    </ListGroup>
  );
}

Enter fullscreen mode Exit fullscreen mode

You'll notice that when we are mapping over the items, we're either rendering an item that is dark (grey) and calls off to removeItemFromList when clicked or we're rendering an item that is danger (red) that calls off to addItemToBasket. Ideally I'd have created two different components and move them into their own file but for demonstration purposes it made more sense to keep them unabstracted.

Both addItemToBasket and removeItemFromList both take in the index of the selected item and simply dispatch that as data along with their relevant action.


Lastly, The Setup

Now that we have everything we need (action creators, a reducer to handle our actions and components to dispatch actions), we need to setup our store so that our application can make use of Redux. You'll need to locate our index.js file and make some simple changes there.

You'll need to import creatStore from the redux library as well as Provider from the react-redux library. We'll use createStore to generate a store from the reducer we created. Your index should look something like this:

import React from "react";
import { render } from "react-dom";
import { createStore } from "redux";
import { Provider } from "react-redux";
import App from "./App";
import reducer from "./store/reducer";

const store = createStore(reducer);

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

Enter fullscreen mode Exit fullscreen mode

Now our application will be able to make use of Redux because Provider makes the store available to any nested components.


You should have everything you need to get this application up and running. If there is anything that is unclear, check out my CodeSandBox which will provide you full access to repo so that you can see the entire solution or just mess around.

Top comments (0)