MERN Stack App pt-3

Full CRUD operations with the MERN stack

Published 6th July 2022

In the last article we have connected the MERN application we had built to a mongoDB cloud instance and now we can read from and write to the mongo instance.

In case you have not built the project so far up until here, you can do so by reading the other 2 articles on my blog. In case you just want to start from here, you'll have to clone this repo and reset it to a specific commit:

      git reset --hard 124f53be30e274d8820aa00723ce1ce97ac6bdff
    

You will also need a mongoURI (a connection string for the cloud database), we have set that up in the previous article .

After you have cloned the repo, run yarn or npm i in the front and back directories and add the .env in the back one with the mongoURI.

Finally, cd into front and run 'yarn dev'.

Our app currently allows us to read from the DB and to write into it. We will be implementing now the full CRUD functionalities.

Let's start with some delete all functionality. We want to be able to delete all the entries in 1 swoop. We will start working in the front in the BasicButton, replace its contents as per below:

import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
export const BasicButton = ({
  clickHandler,
  color = "secondary",
  caption = "Add Meal",
}) => {
  return (
    <Grid>
      <Button
        onClick={clickHandler}
        variant="contained"
        color={color}
        size="large"
      >
        {caption}
      </Button>
    </Grid>
  );
};
    

Next in the MainCard paste one more BasicButton right under the first one:

      <BasicButton
        color="error"
        caption="Clear All"
        clickHandler={deleteMeals}
      />
    

Next we need to create the deleteMeals(). Paste it right under saveMeal() in MainCard:

      const deleteMeals = () => {
        setIsLoading(true);
        fetch("http://localhost:4000/meals/clearAll", {
          method: "POST",
          headers: {
            Accept: "application/json, text/plain, */*",
            "Content-Type": "application/json",
          },
        })
          .then((res) => res.json())
          .then((res) => {
            console.log(res);
            setIsLoading(false);
            setMealItems([]);
          });
      };
    

Before testing, it, we need to add the /clearAll endpoint in the 'back' in server.js. Paste it there:

      app.post("/meals/clearAll", async (req, res) => {
        await clearItems(MealItem);
        res.send(JSON.stringify({ msg: "delete all successfull" }));
      });
    

Don't forget to add the clearItems() method in the db import up top in the file.

Finally, paste the clearItems() method in the db.js file:

      export const clearItems = async (model) => {
        connectDB();
        try {
          await model.deleteMany({});
        } catch (err) {
          console.log(err);
        }
      };      
    

Finally, you can try your deleteAll method. Push the button and see the magic happen. Check in your DB too and confirm that the items are actually delete.

Next, let's work on a delete functionality for each item. We need to edit a bit some stuff inside the MealItems.js file, more precisely inside the map() call in there. First in the 'front' terminal run:

     yarn add @mui/icons-material
    

Next, add 2 imports up top in the file:

      import Button from "@mui/material/Button";
      import DeleteIcon from "@mui/icons-material/Delete";
    

Next, paste the delete button in the map() call, right under the Typography component that renders the item's protein:

     <Button
      color="error"
      variant="outlined"
      style={btnStyles}
      onClick={() => deleteItem(i._id)}
      startIcon={<DeleteIcon />}
    >
      Delete
   </Button>
    

Pass in the deleteItem() method as a prop through the MealItems component. Don't forget to pass it to MealItems itself from the MainCard:

      <MealItems items={mealItems} deleteItem={deleteItem} />
    

Back in MealItems, paste the btnStyles in the same file under the mealStyles:

      const btnStyles = {
        backgroundColor: "#fff",
        display: "inline-block",
        margin: "0.5rem",
        width: "8rem",
      };
    

Now, we need to create the deleteItem() method in the MainCard.js. However, before doing that, we need to do a small refactor in the same MainCard.js. Add in a fetchItems() method that looks like this:

      const fetchItems = () => {
        setIsLoading(true);
        fetch("http://localhost:4000/meals", {
          method: "GET",
          headers: {
            Accept: "application/json, text/plain, */*",
            "Content-Type": "application/json",
          },
        })
          .then((res) => res.json())
          .then((res) => {
            setIsLoading(false);
            setMealItems(res);
          }, 2000);
      };
    

Now, replace the useEffect() call in the component with:

      useEffect(() => {
        fetchItems();
      }, []);
    

The reason, we moved this logic in a separate method is that we will be using it. Next, paste in the deleteItem() method:

      const deleteItem = (id) => {
        setIsLoading(true);
        fetch(`http://localhost:4000/meals/${id}`, {
          method: "POST",
        })
          .then((res) => res.json())
          .then((res) => {
            console.log(res);
            setIsLoading(false);
            fetchItems();
          }, 2000);
      };
    

Next, we need to add the /:id endpoint on our server. Paste it there:

      app.post("/meals/:id", async (req, res) => {
        const { id } = req.params;
        await removeItem(id, MealItem);
        res.send(JSON.stringify({ msg: `id ${id} deleted successfully` }));
      });
    

Again, add the removeItem() method in the db.js import up top.

Finally, in the db.js paste in the removeItem() method:

      export const removeItem = async (id, model) => {
        connectDB();
        try {
          await model.remove({ _id: id });
        } catch (err) {
          console.log(err);
        }
      };        
    

Before we test the delete logic, replace the saveMeal() method as per below:

      const saveMeal = () => {
        if (!mealItem || !mealQty || !mealCals || !mealProtein) {
          console.log("boo! bad input! X__X");
        } else {
          const totalCalories = (mealQty * mealCals) / 100;
          const totalProtein = (mealQty * mealProtein) / 100;
          const newMealItem = {
            mealItem,
            mealQty,
            mealCals: totalCalories,
            mealProtein: totalProtein,
          };
          const newMeals = [...mealItems, newMealItem];
          setMealItems(newMeals);
          setMealItem("");
          setMealQty(0);
          setMealCals(0);
          setProtein(0);
    
          fetch("http://localhost:4000/meals", {
            method: "POST",
            headers: {
              Accept: "application/json, text/plain, */*",
              "Content-Type": "application/json",
            },
            body: JSON.stringify(newMealItem),
          })
            .then((res) => res.json())
            .then((res) => {
              console.log(res);
              fetchItems();
            });
        }
      };
    

We did this, because now, we need to fetch all items upon every addition of a new item (otherwise if we tried adding an item and immediately deleting it, without refreshing first, we would get undefined for the id which we use to delete by).

We have now implemented the delete functionality. Next, let's implement some edit logic. First, add 1 more import in the MealItems.js:

      import EditIcon from '@mui/icons-material/Edit';
    

Next, under the delete button paste:

      <Button
        color="primary"
        variant="outlined"
        style={btnStyles}
        onClick={() => editItem(i)}
        startIcon={<EditIcon />}
      >
        Edit
      </Button>
    

Don't forget to send is a prop the editItem() method in MealItems.

We need now to change something in the CustomTextField.js file, namely the value of the StyledTextField, change it with this:

      value={typeof value === "string" || value > 0 ? value : ""}
    

Add one more piece of state in the MainCard.js under isLoading:

      const [idToEdit, setId] = useState(null);
    

Under the 'Clear All' button paste:

      {idToEdit && (
        <BasicButton
          color="success"
          caption="Save Edit"
          clickHandler={saveEditMeal}
        />
      )}
    

Next, pass the editItem() method in as prop to the MealItems component and paste the method just under deleteItem():

      const editItem = (itemToEdit) => {
        const { _id, mealItem, mealQty, mealCals, mealProtein } = itemToEdit;
        setMealItem(mealItem);
        setMealQty(mealQty);
        setMealCals(mealCals);
        setProtein(mealProtein);
        setId(_id);
      };
    

By this time, the app should complain about the saveEditMeal that does not exist, paste it under the editItem:

      const saveEditMeal = () => {
        const totalCalories = (mealQty * mealCals) / 100;
        const totalProtein = (mealQty * mealProtein) / 100;
        const editMealData = {
          mealItem,
          mealQty,
          mealCals: totalCalories,
          mealProtein: totalProtein,
          idToEdit,
        };
        fetch("http://localhost:4000/edit/meal", {
          method: "POST",
          headers: {
            Accept: "application/json, text/plain, */*",
            "Content-Type": "application/json",
          },
          body: JSON.stringify(editMealData),
        })
          .then((res) => res.json())
          .then(() => {
            setMealItem("");
            setMealQty(0);
            setMealCals(0);
            setProtein(0);
            setId(null);
            fetchItems();
          });
      };
    

Client side we are done, let's now add the /edit/meal endpoint in the server.js:

      app.post("/edit/meal", async (req, res) => {
        await editItem(req.body, MealItem);
        const { idToEdit: id } = req.body;
        res.send(JSON.stringify({ msg: `id ${id} edited successfully` }));
      });
    

Add the editItem() in the db.js import.

Finally, paste the editItem() in the db.js file:

      export const editItem = async (payload, model) => {
        connectDB();
        const { idToEdit: _id, mealItem, mealQty, mealCals, mealProtein } = payload;
        try {
          await model.findOneAndUpdate(
            { _id },
            { mealItem, mealQty, mealCals, mealProtein }
          );
        } catch (err) {
          console.log(err);
        }
      };
    

Now, the edit functionality should work. Congrats! You have implemented full CRUD operations into our little full-stack app. Before wrapping everything up, we will be doing some refactoring through our code (both front and back) as it became quite messy, and although at this level, it is fairly easy to maintain it, if we add more functionalities it will become some sort of spagetti-code which we do not want.

Our code so far does not really scale well. If we want to implement 10 more functionalities and we keep doing what we've been doing (just cramming logic in the components or in the back-end entry point) we will soon reach a point where it will be impossible to maintain our code (not even dare to dream of developing it further).

Let's start in the front. The MainCard is the most overcrowded component. There's a bunch of duplicated code there and we need to change that. Note that the fetch(http://someUrl...) code appears like 5 times. There's definitely some duplicated code here that we must eliminate. The blocks of code that do this basically make http calls to our server. Let's outsource that logic in a service. In case you don't know what services are, well they are modules that interact with the stuff outside of your app (they fetch data from 3rd party sources) or they can also better connect modules in your application if it grows. For now let's create a 'services' directory in the 'src' one. Inside of it create a http.js and inside of it paste the below:

      export const postData = async (url, payload = {}) => {
        const res = await fetch(url, {
          method: "POST",
          headers: {
            Accept: "application/json, text/plain, */*",
            "Content-Type": "application/json",
          },
          body: JSON.stringify(payload),
        });
        const resData = await res.json();
        return resData;
      };      
    

Next, import the postData method in MainCard and replace the saveMeal() as per below:

      const saveMeal = async () => {
        if (!mealItem || !mealQty || !mealCals || !mealProtein) {
          console.log("boo! bad input! X__X");
        } else {
          const totalCalories = (mealQty * mealCals) / 100;
          const totalProtein = (mealQty * mealProtein) / 100;
          const newMealItem = {
            mealItem,
            mealQty,
            mealCals: totalCalories,
            mealProtein: totalProtein,
          };
          const newMeals = [...mealItems, newMealItem];
          setMealItems(newMeals);
          setMealItem("");
          setMealQty(0);
          setMealCals(0);
          setProtein(0);
    
          const resData = await postData(
            "http://localhost:4000/meals",
            newMealItem
          );
    
          console.log(resData);
        }
      };
    

Next replace the deleteMeals() method:

    const deleteMeals = async () => {
      setIsLoading(true);
      const resData = await postData("http://localhost:4000/meals/clearAll");
      console.log(resData);
      setIsLoading(false);
      setMealItems([]);
    };
    

We have 'trimmed' down the save method a bit, but we can still do more. Above it, add the next method:

      const processMealInput = () => {
        if (!mealItem || !mealQty || !mealCals || !mealProtein) {
          console.log("boo! bad input! X__X");
        } else {
          const totalCalories = (mealQty * mealCals) / 100;
          const totalProtein = (mealQty * mealProtein) / 100;
          const newMealItem = {
            mealItem,
            mealQty,
            mealCals: totalCalories,
            mealProtein: totalProtein,
          };
          const newMeals = [...mealItems, newMealItem];
          setMealItems(newMeals);
          setMealItem("");
          setMealQty(0);
          setMealCals(0);
          setProtein(0);
          return newMealItem;
        }
      };
    

Finally, replace saveMeal again:

      const saveMeal = async () => {
        const newMealItem = processMealInput();
        if (newMealItem) {
          const resData = await postData(
            "http://localhost:4000/meals",
            newMealItem
          );
          console.log(resData);
          await fetchItems();
        }
      };
    

Replace deleteItem() too:

      const deleteItem = async (id) => {
        setIsLoading(true);
        const resData = await postData(`http://localhost:4000/meals/${id}`);
        console.log(resData);
        setIsLoading(false);
        await fetchItems();
      };
    

Replace saveEditMeal():

      const saveEditMeal = async () => {
        const editItem = processMealInput();
        editItem.idToEdit = idToEdit;
        const resData = await postData("http://localhost:4000/edit/meal", editItem);
        await fetchItems();
        console.log(resData);
        setId(null);
      };
    

Next, let's add 1 more method to the http module:

      export const getData = async (url) => {
        const rawData = await fetch("http://localhost:4000/meals", {
          method: "GET",
          headers: {
            Accept: "application/json, text/plain, */*",
            "Content-Type": "application/json",
          },
        });
        const serialisedData = await rawData.json();
        return serialisedData;
      };
    

Import it in the MainCard and next refactor the fetchItems() method:

      const fetchItems = async () => {
        setIsLoading(true);
        const items = await getData("http://localhost:4000/meals");
        setMealItems(items);
        setIsLoading(false);
      };
    

I want us to improve the edit functionality a bit. First, wrap the AddMeal button in the below logic block:

      {!idToEdit && <BasicButton clickHandler={saveMeal} />}
    

Next, add a CancelEdit button right below the SaveEdit one (in the same logic block):

      {idToEdit && (
        <>
          <BasicButton
            color="success"
            caption="Save Edit"
            clickHandler={saveEditMeal}
          />
          <BasicButton
            color="secondary"
            caption="Cancel"
            clickHandler={cancelEdit}
          />
        </>
      )}
    

Next paste the cancelEdit method:

      const cancelEdit = () => {
        setId(null);
        setMealItem("");
        setMealQty(0);
        setMealCals(0);
        setProtein(0);
      };
    

Add one more style attribute to the BasicButton.js

      style={{ minWidth: "10rem" }}
    

Now all the buttons look good and are equal regardless of the text on them.

Add one more method in the MainCard:

      const clearInput = () => {
        setMealItem("");
        setMealQty(0);
        setMealCals(0);
        setProtein(0);
      };
    

Now everywhere where we clear the input, replace the 4 lines with the clearInput() call.

Finally, call the clearInput() in the deleteMeals() too, just so that we better handle our clearing of data. Below the clearInput, also add: setId(null).

I want now to have a look at MealItems.js. In it, there is a map() call and inside of it we manually build the meal items. Let's create a MealItem component:

import Grid from "@mui/material/Grid";
import Typography from "@mui/material/Typography";

import Button from "@mui/material/Button";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";

const mealStyles = {
  border: "2px solid #fff",
  borderRadius: "15px",
  marginTop: "1rem",
  marginBottom: "1rem",
};

const btnStyles = {
  backgroundColor: "#fff",
  display: "inline-block",
  margin: "0.5rem",
  width: "8rem",
};
export const MealItem = ({ item, deleteItem, editItem }) => {
  return (
    <Grid style={mealStyles} item xs={12} md={7} >
      <Typography variant="h5" component="div">
        {item.mealItem}
      </Typography>
      <Typography variant="h6" component="div">
        Qty: {item.mealQty} g
      </Typography>
      <Typography variant="h6" component="div">
        Cal: {item.mealCals}
      </Typography>
      <Typography variant="h6" component="div">
        Protein: {item.mealProtein}
      </Typography>
      <Button
        color="error"
        variant="outlined"
        style={btnStyles}
        onClick={() => deleteItem(item._id)}
        startIcon={<DeleteIcon />}
      >
        Delete
      </Button>
      <Button
        color="primary"
        variant="outlined"
        style={btnStyles}
        onClick={() => editItem(item)}
        startIcon={<EditIcon />}
      >
        Edit
      </Button>
    </Grid>
  );
};
    

Next, import it in the MealItems and use it in the map:

import Grid from "@mui/material/Grid";
import Typography from "@mui/material/Typography";

import { MealItem } from "./MealItem";

export const MealItems = ({ items = [], deleteItem, editItem }) => {
  let totalCals = 0;
  let totalP = 0;
  items.map((i) => (totalCals += i.mealCals));
  items.map((i) => (totalP += i.mealProtein));

  if (items.length === 0) {
    return;
  } else {
    return (
     <Grid container spacing={1} justifyContent="center">
       <Grid item xs={12} md={7}>
         <Typography variant="h4" component="div">
            Meal Items:
         </Typography>
       </Grid>
        {items.map((i) => {
          return (
           <MealItem
              item={i}
              deleteItem={deleteItem}
              editItem={editItem}
              key={Math.random()}
            />
          );
        })}
       <Grid item xs={12} md={7}>
         <Typography variant="h4" component="div">
            Meals Total Calories: {totalCals.toFixed(2)}
           <br />({totalP.toFixed(2)} g Protein)
         </Typography>
       </Grid>
     </Grid>
    );
  }
};
    

In the same MealItem.js I want to replace the regular buttons there with our own component (BasicButton ). First change as per below the BasicButton:

import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
export const BasicButton = ({
  clickHandler,
  color = "secondary",
  caption = "Add Meal",
  startIcon,
  style,
  variant = "contained",
}) => {
  return (
    <Grid>
      <Button
        onClick={clickHandler}
        variant={variant}
        color={color}
        size="large"
        style={{ ...style, minWidth: "9rem" }}
        startIcon={startIcon}
      >
        {caption}
      </Button>
    </Grid>
  );
};

    

Next in the MealItem import the BasicButton replace the edit and delete buttons with it:

      <BasicButton
      caption="Delete"
      color="error"
      style={btnStyles}
      startIcon={<DeleteIcon />}
      variant="outlined"
      clickHandler={() => deleteItem(item._id)}
    />
    <BasicButton
      caption="Edit"
      color="primary"
      style={btnStyles}
      startIcon={<EditIcon />}
      variant="outlined"
      clickHandler={() => editItem(item)}
    />
    

Let's go one step further and put each component that has its own custon styles in their own directory and split the style in a separate file too. Start with the MainCard.js move it in it's own directory (name it MainCard). Remember to update the import in App.js for it.

Next, move the Item from MainCard.js in its own file and import it back into MainCard:

import { styled } from "@mui/material/styles";
import Paper from "@mui/material/Paper";

export const Item = styled(Paper)(() => ({
  textAlign: "center",
  backgroundColor: "#077ae6",
  color: "#fff",
  lineHeight: "60px",
  marginTop: "5rem",
}));
    

Next, move the CustomTextField in its own folder, and in a similar way separate the StyledTextField in its own separate file. Remember to update the import for CustomTextField in MainCard.js.

Do the same thing for MealItem.js (split out in a separate file the styles in there).

I just want us to change 1 more tiny thing before we wrap everything up. In the MealItems.js there's a key attribute set to Math.random() in order to render the list. This was good enough until we have set up our model and DB. Now that we have an actual unique id on the item, let's replace the Math.random() with that:

      key={i._id}
    

If you have stuck with me so far, you should be proud of yourself. You've implemented your full-stack CRUD operations with the MERN stack. We will be working some more on the project, in the next section we will document it a bit and improve upon it some more.

For the full code of this project you can see this repo , if you want the specific commit from this article, here you have it !

Feel free to leave some comments in the comment section at the bottom of the article, and stay tuned for more!