Clone this repo into your projects folder and cd into it.
Remove the .git directory with rm -rf .git.
We will run our backend on port 3456. Change the setting in the backend .env file.
- cd into the backend,
- initialize it as a git repo,
- NPM install all dependencies,
Check if any Docker containers are running:
$ docker container lsIf not, start Docker and run docker run --name recipes-mongo -dit -p 27017:27017 --rm mongo:4.4.1
Here's the Docker CLI Reference.
Some samples of running Docker CLI on the command line:
$ docker stop recipes-mongo
$ docker run --name recipes-mongo -dit -p 27017:27017 --rm mongo:4.4.1
$ docker exec -it recipes-mongo mongo
$ docker kill my_container- start the backend:
npm run start:dev - test by visiting localhost on port 3456
- ensure that the database contains some recipes
- if not import some
cd into the top level of the project directory and run Create React App:
npx create-react-app client
cd into the client folder and remove the contents of the src folder and create an initial index.js file:
$ cd client
$ rm src/*
$ touch src/index.jsSet a color for VSCode's display of the front end.
In a new file: client/.vscode/settings.json:
{
"workbench.colorCustomizations": {
"titleBar.activeForeground": "#fff",
"titleBar.inactiveForeground": "#ffffffcc",
"titleBar.activeBackground": "#235694",
"titleBar.inactiveBackground": "#235694CC"
}
}Create a simple start page in index.js:
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<App />);Set a Proxy in the React client package.json.
"proxy": "http://localhost:3456/",
This will enable us to use 'fetch(/api/recipes)' instead of 'fetch(http://localhost:3456/api/recipes)'.
Create App.js:
import React from "react";
function App() {
const [recipes, setRecipes] = React.useState([]);
React.useEffect(() => {
fetch(`/api/recipes`)
.then((response) => response.json())
.then((data) => console.log(data));
});
return (
<div>
<p>Hello from App</p>
</div>
);
}
export default App;Add a new index.css file in src:
body {
max-width: 940px;
margin: 0 auto;
font-family: sans-serif;
color: #333;
background-color: #eee;
}
a {
color: #007eb6;
}
main {
padding: 1rem;
background-color: #fff;
}
summary {
margin: 1rem 0;
border-bottom: 1px dotted #666;
}
img {
max-width: 200px;
}
input,
textarea {
font-size: 1rem;
display: block;
margin: 1rem;
width: 90%;
padding: 0.5rem;
font-family: inherit;
}
label {
margin: 1rem;
padding: 0.5rem;
}
button {
color: #fff;
font-size: 1rem;
padding: 0.5rem;
margin: 0 1rem;
background: #007eb6;
border: none;
border-radius: 3px;
}
button.delete {
background: unset;
margin: unset;
border: none;
padding: 0;
color: #007eb6;
cursor: pointer;
}Import the basic CSS into index.js:
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
...If you ever need to deal with CORS, add the following middleware to server.js:
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
res.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE");
next();
});Use a Recipe component to display the recipes.
In App.js:
import React from "react";
function App() {
const [recipes, setRecipes] = React.useState([]);
React.useEffect(() => {
fetch(`/api/recipes`)
.then((response) => response.json())
.then((data) => setRecipes(data));
}, []);
return (
<div>
{recipes.map((recipe) => (
<Recipe key={recipe._id} recipe={recipe} />
))}
</div>
);
}
function Recipe(props) {
return <p>{props.recipe.title}</p>;
}
export default App;Breakout the Recipe component into a separate src/Recipe.js file:
import React from "react";
function Recipe(props) {
return <p>{props.recipe.title}</p>;
}
export default Recipe;Import it and compose it in App.js:
import React from "react";
import Recipe from "./Recipe";
function App() {
const [recipes, setRecipes] = React.useState([]);
React.useEffect(() => {
fetch(`/api/recipes`)
.then((response) => response.json())
.then((data) => setRecipes(data));
}, []);
return (
<div>
{recipes.map((recipe) => (
<Recipe key={recipe._id} recipe={recipe} />
))}
</div>
);
}
// function Recipe(props) {
// return <p>{props.recipe.title}</p>;
// }
export default App;Build out the Recipe component to display additional data:
import React from "react";
function Recipe({ recipe }) {
const { title, created, description, image, _id } = recipe;
return (
<summary>
<img src={`img/${image}`} alt={title} />
<h3>
<a href={_id}>{title}</a>
</h3>
<p>{description}</p>
<small>Published: {formatDate(created)}</small>
</summary>
);
}
export default Recipe;src/utils.js:
export function formatDate(timestamp) {
// Create a date object from the timestamp
let date = new Date(timestamp);
// Create a list of names for the months
let months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
// return a formatted date
return (
months[date.getMonth()] + " " + date.getDate() + ", " + date.getFullYear()
);
}In preparation for the next steps, create two new components. We'll use these components in the next steps to explore routing.
A Recipes component:
import React from "react";
import Recipe from "./Recipe";
function Recipes({ recipes }) {
return (
<summary>
{recipes.map((recipe) => (
<Recipe key={recipe._id} recipe={recipe} />
))}
</summary>
);
}
export default Recipes;And a RecipeDetail component:
import React from "react";
function RecipeDetail(props) {
return (
<details>
<pre>{JSON.stringify(props, null, 2)}</pre>
</details>
);
}
export default RecipeDetail;Change App.js to import and render Recipes.js:
import React from "react";
import Recipes from "./Recipes";
function App() {
const [recipes, setRecipes] = React.useState([]);
React.useEffect(() => {
fetch(`/api/recipes`)
.then((response) => {
if (response.ok) {
return response.json();
} else {
console.log("something went wrong");
return Promise.reject(response);
}
})
.then((data) => setRecipes(data))
.catch((err) => console.log(err));
}, []);
return (
<main>
<Recipes recipes={recipes} />
</main>
);
}
export default App;Note the check for response.ok. See this article.
Up until this point, you have dealt with simple projects that do not require transitioning from one view to another. We have yet to work with Routing in React.
In SPAs, routing is the ability to move between different parts of an application when a user enters a URL or clicks an element without actually going to a new HTML document or refreshing the page.
We could build a "list / detail" type site without routing but it is important to have an introduction to it so that you can use it in larger projects.
To begin exploring client side routing we'll use the React Router.
Note: be sure you are in the client directory before installing React related packages.
npm install the latest version of react router and import the router into App.js.
npm i react-router-domBegin configuring App.js for routing:
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Recipes from "./Recipes";
import RecipeDetail from "./RecipeDetail";
function App() {
const [recipes, setRecipes] = React.useState([]);
React.useEffect(() => {
fetch(`/api/recipes`)
.then((response) => {
if (response.ok) {
return response.json();
} else {
return Promise.reject(response);
}
})
.then((data) => setRecipes(data))
.catch((err) => console.log(err));
}, []);
return (
<main>
<BrowserRouter>
<Recipes recipes={recipes} />
</BrowserRouter>
</main>
);
}
export default App;Add a Route:
<BrowserRouter>
<Routes>
<Route path="/" element={<Recipes recipes={recipes} />} />
</Routes>
</BrowserRouter>Add a second Route:
<BrowserRouter>
<Routes>
<Route path="/" element={<Recipes recipes={recipes} />} />
<Route path="/:recipeId" element={<RecipeDetail recipes={recipes} />} />
</Routes>
</BrowserRouter>Use the router's Link component in Recipe.js:
import React from "react";
import { formatDate } from "./utils";
import { Link } from "react-router-dom";
function Recipe({ recipe }) {
const { title, created, description, image, _id } = recipe;
return (
<summary>
<img src={`img/${image}`} alt={title} />
<h3>
<Link to={_id}>{title}</Link>
</h3>
<p>{description}</p>
<small>Published: {formatDate(created)}</small>
</summary>
);
}
export default Recipe;The Link component is used to navigate to a new route. It ensures that the page does not refresh when the link is clicked. The to prop is set to the recipe's id.
Check for browser refresh on the new route by watching the console.
Here is the entire App component:
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Recipes from "./Recipes";
import RecipeDetail from "./RecipeDetail";
function App() {
const [recipes, setRecipes] = React.useState([]);
React.useEffect(() => {
fetch(`/api/recipes`)
.then((response) => {
if (response.ok) {
return response.json();
} else {
return Promise.reject(response);
}
})
.then((data) => setRecipes(data))
.catch((err) => console.log(err));
}, []);
return (
<main>
<BrowserRouter>
<Routes>
<Route path="/" element={<Recipes recipes={recipes} />} />
<Route
path="/:recipeId"
element={<RecipeDetail recipes={recipes} />}
/>
</Routes>
</BrowserRouter>
</main>
);
}
export default App;App.js imports and renders Recipes.js and renders either Recipes.js or RecipesDetail.js depending on the Route.
Edit the RecipeDetail component:
import React from "react";
import { useParams } from "react-router-dom";
function RecipeDetail(props) {
const { recipeId } = useParams();
const currRecipe = props.recipes.filter((recipe) => recipe._id === recipeId);
console.log("currRecipe[0]", currRecipe[0]);
return (
<div>
<h2>{currRecipe[0].title}</h2>
</div>
);
}
export default RecipeDetail;Note the use of filter above which returns an array. We can spread the array into a new variable and use <h2>{thisRecipe.title}</h2>.
Add a 'Home' link to RecipeDetail.js and flesh out the return value:
import React from "react";
import { Link, useParams } from "react-router-dom";
function RecipeDetail(props) {
const { recipeId } = useParams();
const currRecipe = props.recipes.filter((recipe) => recipe._id === recipeId);
const thisRecipe = { ...currRecipe[0] };
return (
<div>
<img src={`/img/${thisRecipe.image}`} alt={thisRecipe.title} />
<h1>{thisRecipe.title}</h1>
<p>{thisRecipe.description}</p>
<Link to="/">Home</Link>
</div>
);
}
export default RecipeDetail;We are importing useParams from react-router-dom and are using it to get the recipeId from the URL.
Building custom Hooks lets you extract component logic into reusable functions.
Create a hooks directory and save this as useToggle.js:
import { useState } from "react";
function useToggle(initialVal = false) {
const [state, setState] = useState(initialVal);
const toggle = () => {
setState(!state);
};
// return piece of state AND a function to toggle it
return [state, toggle];
}
export default useToggle;Demo the hook with Toggle.js in index.js:
import useToggle from "./hooks/useToggle";
function Toggler() {
const [isHappy, toggleIsHappy] = useToggle(true);
const [isBanana, toggleIsBanana] = useToggle(true);
return (
<div>
<h1 onClick={toggleIsHappy}>{isHappy ? "😄" : "😢"}</h1>
<h1 onClick={toggleIsBanana}>{isBanana ? "🍌" : "👹"}</h1>
</div>
);
}In the hooks directory, copy the custom hook below :
import React from "react";
export function useFetch(url) {
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setLoading(true);
fetch(url)
.then((res) => res.json())
.then((data) => {
setData(data);
setError(null);
setLoading(false);
})
.catch((error) => {
console.warn(error.message);
setError("error loading data");
setLoading(false);
});
}, [url]);
return {
loading,
data,
error,
};
}- import the hook into App.js:
import { useFetch } from "./hooks/useFetch";- Remove the useEffect from App.js
- Destructure useFetch's return values:
function App() {
const { loading, data: recipes, error } = useFetch(`/api/recipes`);- the routes pass data the the recipes component:
<BrowserRouter>
<Routes>
<Route path="/" element={<Recipes recipes={recipes} />} />
<Route path="/:recipeId" element={<RecipeDetail recipes={recipes} />} />
</Routes>
</BrowserRouter>- Finally, add the loading and error early returns to the App component:
if (loading === true) {
return <p>Loading</p>;
}
if (error) {
return <p>{error}</p>;
}Here is App.js in its entirety:
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Recipes from "./Recipes";
import RecipeDetail from "./RecipeDetail";
import { useFetch } from "./hooks/useFetch";
function App() {
const { loading, data: recipes, error } = useFetch(`/api/recipes`);
if (loading === true) {
return <p>Loading</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Recipes recipes={recipes} />} />
<Route path="/:recipeId" element={<RecipeDetail recipes={recipes} />} />
</Routes>
</BrowserRouter>
);
}
export default App;Create Nav.js in src:
import React from "react";
import { Link } from "react-router-dom";
const Nav = ({ loggedin, setLoggedin }) => {
return (
<nav>
<h1>
<Link to="/">Recipes</Link>
</h1>
{loggedin ? (
<button onClick={() => setLoggedin(false)}>Log Out</button>
) : (
<button onClick={() => setLoggedin(true)}>Log In</button>
)}
</nav>
);
};
export default Nav;Import it and render in App.js:
import React from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Recipes from "./Recipes";
import RecipeDetail from "./RecipeDetail";
import Nav from "./Nav";
import { useFetch } from "./hooks/useFetch";
function App() {
const [loggedin, setLoggedin] = React.useState(false);
const { loading, data: recipes, error } = useFetch(`/api/recipes`);
if (loading === true) {
return <p>Loading</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<main>
<BrowserRouter>
<Nav setLoggedin={setLoggedin} loggedin={loggedin} />
<Routes>
<Route path="/" element={<Recipes recipes={recipes} />} />
<Route
path="/:recipeId"
element={<RecipeDetail recipes={recipes} />}
/>
</Routes>
</BrowserRouter>
</main>
);
}
export default App;Install styled components:
npm i styled-components
and create a Styled Component to support the new nav:
import styled from "styled-components";
const StyledNav = styled.nav`
min-height: 3rem;
background-color: #007eb6;
margin-bottom: 1rem;
display: flex;
flex-direction: row;
align-content: center;
justify-content: space-between;
a {
color: #fff;
padding: 1rem;
font-size: 2rem;
text-decoration: none;
}
button {
color: #fff;
font-size: 1rem;
padding: 0.5rem;
margin: 0 1rem;
background: #007eb6;
border: 2px solid #fff;
border-radius: 3px;
align-self: center;
}
`;In the Nav component:
import React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
const StyledNav = styled.nav`
min-height: 3rem;
background-color: #007eb6;
margin-bottom: 1rem;
display: flex;
flex-direction: row;
align-content: center;
justify-content: space-between;
a {
color: #fff;
padding: 1rem;
font-size: 2rem;
text-decoration: none;
}
button {
color: #fff;
font-size: 1rem;
padding: 0.5rem;
margin: 0 1rem;
background: #007eb6;
border: 2px solid #fff;
border-radius: 3px;
align-self: center;
}
`;
const Nav = ({ loggedin, setLoggedin }) => {
return (
<StyledNav>
<h1>
<Link to="/">Recipes</Link>
</h1>
{loggedin ? (
<button onClick={() => setLoggedin(false)}>Log Out</button>
) : (
<button onClick={() => setLoggedin(true)}>Log In</button>
)}
</StyledNav>
);
};
export default Nav;An element shouldn’t set its width, margin, height and color. These attributes should be contolled by its parent.
Remove the button styles in Nav and review CSS variables by using a variable to set the background color of Nav:
const StyledNav = styled.nav`
--bg-color: #007eb6;
min-height: 3rem;
background-color: var(--bg-color);
margin-bottom: 1rem;
display: flex;
flex-direction: row;
align-content: center;
justify-content: space-between;
a {
color: #fff;
padding: 1rem;
font-size: 2rem;
text-decoration: none;
}
`;Create a Button component in src/Button.js:
import React from "react";
import styled from "styled-components";
const StyledButton = styled.button`
--btn-bg: var(--btn-color, #bada55);
color: #fff;
font-size: 1rem;
padding: 0.5rem;
margin: 0 1rem;
background: var(--btn-bg);
border: 2px solid #fff;
border-radius: 3px;
align-self: center;
cursor: pointer;
`;
export default function Button({ children, func }) {
return <StyledButton onClick={func}>{children}</StyledButton>;
}Note the second color in the CSS variable: --btn-bg: var(--btn-color, #bada55);. This sets a variable and provides a fallback.
Import it into Nav
import Button from "./Button";
and compose it:
{ loggedin ? (
<Button func={() => setLoggedin(false)}>Log Out</Button>
) : (
<Button func={() => setLoggedin(true)}>Log In</Button>
)
}Set it to use a color variable passed in from Nav:
const StyledNav = styled.nav`
--bg-color: #007eb6;
--btn-color: #007eb6;--btn-color: #007eb6; overrides the default in our Button component:
--btn-bg: var(--btn-color, #bada55);
we probably want to store our color palette at a higher level. Add to index.css:
:root {
--blue-dark: #046e9d;
}In Nav.js:
--btn-color: var(--blue-dark);This is the beginning of our standalone Button component.
We will also use the toggle hook:
import { useState } from "react";
function useToggle(initialVal = false) {
const [state, setState] = useState(initialVal);
const toggle = () => {
setState(!state);
};
// return piece of state AND a function to toggle it
return [state, toggle];
}
export default useToggle;Import the useToggle hook into App and use it to toggle the visibility of the RecipeDetail component.
Note: const [loggedin, setLoggedin] = useToggle(false);
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Recipes from "./Recipes";
import RecipeDetail from "./RecipeDetail";
import Nav from "./Nav";
import { useFetch } from "./hooks/useFetch";
import useToggle from "./hooks/useToggle";
function App() {
const [loggedin, setLoggedin] = useToggle(false);
const { loading, data: recipes, error } = useFetch(`/api/recipes`);
if (loading === true) {
return <p>Loading</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<BrowserRouter>
<Nav setLoggedin={setLoggedin} loggedin={loggedin} />
<Routes>
<Route path="/" element={<Recipes recipes={recipes} />} />
<Route path="/:recipeId" element={<RecipeDetail recipes={recipes} />} />
</Routes>
</BrowserRouter>
);
}
export default App;Create FormCreateRecipe.js:
import React from "react";
const FormCreateRecipe = () => {
return (
<div>
<h3>Add Recipe Form</h3>
<form>
<input type="text" placeholder="Recipe name" />
<input type="text" placeholder="Recipe image" />
<textarea type="text" placeholder="Recipe description" />
<button type="submit">Add Recipe</button>
</form>
</div>
);
};
export default FormCreateRecipe;Allow it to render only if the user is logged in.
- pass the logged in state from
App.jsto the Recipes component:
<Route path="/" element={<Recipes recipes={recipes} loggedin={loggedin} />} />Import the component into Recipes.js:
import React from "react";
import Recipe from "./Recipe";
import FormCreateRecipe from "./FormCreateRecipe";
function Recipes({ recipes, loggedin }) {
return (
<section>
{loggedin && <FormCreateRecipe />}
{recipes.map((recipe) => (
<Recipe key={recipe._id} recipe={recipe} />
))}
</section>
);
}
export default Recipes;Add values state, handleInputchange and createRecipe functions to FormCreateRecipe.js:
import React from "react";
import Button from "./Button";
const FormCreateRecipe = () => {
const [values, setValues] = React.useState({
title: "Recipe Title",
image: "toast.png",
description: "Description of the recipe",
});
const createRecipe = (event) => {
event.preventDefault();
const recipe = {
title: values.title,
image: values.image,
description: values.description,
year: values.year,
};
console.log(" making a recipe ", recipe);
};
const handleInputChange = (event) => {
const { name, value } = event.target;
console.log(" name:: ", name, " value:: ", value);
// computed property names
setValues({ ...values, [name]: value });
};
return (
<div>
<h3>Add Recipe Form</h3>
<form onSubmit={createRecipe}>
<input
type="text"
placeholder="Recipe title"
value={values.title}
name="title"
onChange={handleInputChange}
/>
<input
type="text"
placeholder="Recipe image"
value={values.image}
name="image"
onChange={handleInputChange}
/>
<textarea
placeholder="Recipe description"
name="description"
onChange={handleInputChange}
value={values.description}
/>
<input
type="text"
placeholder="Recipe year"
value={values.year}
name="year"
onChange={handleInputChange}
/>
<Button type="submit">Add Recipe</Button>
</form>
</div>
);
};
export default FormCreateRecipe;Note the difference between what we are doing here: handling state change for inputs vs. how we accomplished the same task in previous classes. (Examine the console output.)
We are using ES6 computed property names which allow you to have an expression (a piece of code that results in a single value like a variable or function invocation) be computed as a property name on an object.
Review Object assignment and computed values:
var testObj = {};
// dot assignment
testObj.age = 80;
var myKey = "name";
var myValue = "Daniel";
// bracket assignment
testObj[myKey] = myValue;
console.log(testObj);
// Computed Property Names
// Pre ES6:: create the object first, then use bracket notation to assign that property to the value
function objectify(key, value) {
let obj = {};
obj[key] = value;
return obj;
}
objectify("name", "Daniel");
// After: use object literal notation to assign the expression as a property on the object without having to create it first
function objectifyTwo(key, value) {
return {
[key]: value,
};
}
objectifyTwo("name", "Daniel");Test the button. You should see the new recipe in the console.
We will replace our currect useFetch hook with another that doesn't return the data but instead returns a promise. This is a common practice.
Edit the existing useFetch hook:
const defaultHeaders = {
"Content-Type": "application/json",
Accept: "application/json",
};
async function fetchData({ path, method, data, headers }) {
const response = await fetch(path, {
method: method,
body: !!data ? JSON.stringify(data) : null,
headers: !!headers ? headers : defaultHeaders,
}).then((response) => response.json());
return response;
}
export function useFetch() {
return {
get: (path, headers) =>
fetchData({
path: path,
method: "GET",
data: null,
headers: headers,
}),
post: (path, data, headers) =>
fetchData({
path: path,
method: "POST",
data: data,
headers: headers,
}),
put: (path, data, headers) =>
fetchData({
path: path,
method: "PUT",
data: data,
headers: headers,
}),
del: (path, headers) =>
fetchData({
path: path,
method: "DELETE",
data: null,
headers: headers,
}),
};
}
export default useFetch;This is a major change. We need to alter App.js to use the new hook:
import React from "react";
import Recipes from "./Recipes";
import RecipeDetail from "./RecipeDetail";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useFetch } from "./hooks/useFetch";
import Nav from "./Nav";
import useToggle from "./hooks/useToggle";
function App() {
const [recipes, setRecipes] = React.useState([]);
const [loggedin, setLoggedin] = useToggle(true);
const [loading, setLoading] = useToggle(true);
const [error, setError] = React.useState("");
const { get } = useFetch(`/api/recipes`);
/* eslint-disable react-hooks/exhaustive-deps */
React.useEffect(() => {
setLoading(true);
get("/api/recipes")
.then((data) => {
setRecipes(data);
setLoading(false);
})
.catch((error) => {
setLoading(false);
setError(error);
});
}, []);
if (loading === true) {
return <p>Loading</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<main>
<BrowserRouter>
<Nav setLoggedin={setLoggedin} loggedin={loggedin} />
<Routes>
<Route
path="/"
element={<Recipes recipes={recipes} loggedin={loggedin} />}
/>
<Route
path="/:recipeId"
element={<RecipeDetail recipes={recipes} />}
/>
</Routes>
</BrowserRouter>
</main>
);
}
export default App;Import the post method from useFetch. Add an addRecipe function to App.js and make that function available to the Recipes component:
const { get, post } = useFetch(`/api/recipes`);
...
const addRecipe = (recipe) => {
post("/api/recipes", recipe).then((data) => {
setRecipes([data, ...recipes]);
});
};
...
<Route
path="/"
element={
<Recipes
recipes={recipes}
loggedin={loggedin}
addRecipe={addRecipe}
/>
}
/>Note the addition of post from the useFetch custom hook.
Prop drill the addRecipe function to the form:
import React from "react";
import Recipe from "./Recipe";
import FormCreateRecipe from "./FormCreateRecipe";
function Recipes({ recipes, loggedin, addRecipe }) {
return (
<section>
{loggedin && <FormCreateRecipe addRecipe={addRecipe} />}
{recipes.map((recipe) => (
<Recipe key={recipe._id} recipe={recipe} />
))}
</section>
);
}
export default Recipes;In FormCreateRecipe.js, destructure addRecipe and call it with a recipe:
const FormCreateRecipe = ({ addRecipe }) => {
const [values, setValues] = React.useState({
title: "Recipe Title",
image: "toast.png",
description: "Description of the recipe",
year: "2021"
});
const createRecipe = (event) => {
event.preventDefault();
const recipe = {
title: values.title,
image: values.image,
description: values.description,
year: values.year,
};
addRecipe(recipe);
};Test and debug.
Note - we could use backend validation to ensure that the year is a string. We could also add a required field to the year input in the form:
const RecipeSchema = new mongoose.Schema({
title: String,
description: String,
image: String,
// year: String,
year: {
type: String,
required: true,
},
});const [values, setValues] = React.useState({
title: "",
image: "",
description: "",
year: "",
});In App.js:
const { get, post, del } = useFetch(`/api/recipes`);
// new function
const deleteRecipe = (recipeId) => {
console.log("recipeId:", recipeId);
del(`/api/recipes/${recipeId}`).then(window.location.replace("/"));
};
// pass deleteRecipe prop
<Route
path="/:recipeId"
element={
<RecipeDetail
recipes={recipes}
deleteRecipe={deleteRecipe}
loggedin={loggedin}
/>
}
/>;In RecipeDetail.js:
// {props.loggedin && (
<button onClick={() => props.deleteRecipe(thisRecipe._id)}>
delete
</button>
// )}
<Link to="/">Home</Link>;App.js:
const deleteRecipe = (recipeId) => {
console.log("recipeId:", recipeId);
del(`/api/recipes/${recipeId}`).then(
setRecipes((recipes) => recipes.filter((recipe) => recipe._id !== recipeId))
);
};Destructure all props and add a piece of state to determine the view:
import React from "react";
import { Link, useParams } from "react-router-dom";
function RecipeDetail({ recipes, loggedin, deleteRecipe }) {
const { recipeId } = useParams();
const [recipeDeleted, setRecipeDeleted] = React.useState(false);
const currRecipe = recipes.filter((recipe) => recipe._id === recipeId);
const thisRecipe = { ...currRecipe[0] };
const delRecipe = () => {
deleteRecipe(thisRecipe._id);
setRecipeDeleted(true);
};
if (recipeDeleted) {
return (
<>
<p>Recipe deleted!</p>
<Link to="/">Home</Link>
</>
);
}
return (
<div>
<img src={`/img/${thisRecipe.image}`} alt={thisRecipe.title} />
<h1>{thisRecipe.title}</h1>
<p>{thisRecipe.description}</p>
{loggedin && <button onClick={() => delRecipe()}>delete</button>}
<Link to="/">Home</Link>
</div>
);
}
export default RecipeDetail;App.js:
const { get, post, del, put } = useFetch(`/api/recipes`);
// create a new function
const editRecipe = (updatedRecipe) => {
console.log(updatedRecipe);
put(`/api/recipes/${updatedRecipe._id}`, updatedRecipe).then(
get("/api/recipes").then((data) => {
setRecipes(data);
})
);
};Prop drill:
<RecipeDetail
recipes={recipes}
deleteRecipe={deleteRecipe}
loggedin={loggedin}
editRecipe={editRecipe}
/>New component; src/FormEditRecipe.js:
import React from "react";
import Button from "./Button";
const FormEditRecipe = ({ editRecipe, thisRecipe }) => {
const [values, setValues] = React.useState({
title: thisRecipe.title,
image: thisRecipe.image,
description: thisRecipe.description,
year: thisRecipe.year,
});
const updateRecipe = (event) => {
event.preventDefault();
const recipe = {
...thisRecipe,
title: values.title,
image: values.image,
description: values.description,
year: values.year,
};
editRecipe(recipe);
};
const handleInputChange = (event) => {
const { name, value } = event.target;
console.log(" name:: ", name, " value:: ", value);
setValues({ ...values, [name]: value });
};
return (
<div>
<h3>Edit Recipe</h3>
<form onSubmit={updateRecipe}>
<input
type="text"
placeholder="Recipe title"
value={values.title}
name="title"
onChange={handleInputChange}
/>
<input
type="text"
placeholder="Recipe image"
value={values.image}
name="image"
onChange={handleInputChange}
/>
<textarea
placeholder="Recipe description"
name="description"
onChange={handleInputChange}
value={values.description}
/>
<input
type="text"
placeholder="Recipe year"
value={values.year}
name="year"
onChange={handleInputChange}
/>
<Button type="submit">Edit Recipe</Button>
</form>
</div>
);
};
export default FormEditRecipe;Compose it inRecipeDetail.js:
import FormEditRecipe from "./FormEditRecipe";
// destructure
function RecipeDetail({ recipes, loggedin, deleteRecipe, editRecipe }) {
// compose
// {loggedin && (
// <>
<FormEditRecipe thisRecipe={thisRecipe} editRecipe={editRecipe} />
<button onClick={() => delRecipe()}>delete</button>
// </>
// )}Context provides a way to pass data through the component tree without having to pass props down manually at every level. - The React Docs
Create src/RecipesContext.js:
import React from "react";
const RecipesContext = React.createContext();
export default RecipesContext;App.js
import RecipesContext from "./RecipesContext";
...
<RecipesContext.Provider value={recipes}>
<main>
<BrowserRouter>
<Nav setLoggedin={setLoggedin} loggedin={loggedin} />
<Routes>
{/* NOTE - we no longer pass recipes as a prop to Recipes */}
<Route
path="/"
element={<Recipes loggedin={loggedin} addRecipe={addRecipe} />}
/>
<Route
path="/:recipeId"
element={
<RecipeDetail
recipes={recipes}
deleteRecipe={deleteRecipe}
loggedin={loggedin}
editRecipe={editRecipe}
/>
}
/>
</Routes>
</BrowserRouter>
</main>
</RecipesContext.Provider>Recipes.js
import React from "react";
import Recipe from "./Recipe";
import FormCreateRecipe from "./FormCreateRecipe";
import RecipesContext from "./RecipesContext";
function Recipes({ loggedin, addRecipe }) {
const recipes = React.useContext(RecipesContext);
return (
<section>
{loggedin && <FormCreateRecipe addRecipe={addRecipe} />}
{recipes.map((recipe) => (
<Recipe key={recipe._id} recipe={recipe} />
))}
</section>
);
}
export default Recipes;RecipeDetail.js
import RecipesContext from "./RecipesContext";
function RecipeDetail({ loggedin, deleteRecipe, editRecipe }) {
const recipes = React.useContext(RecipesContext);
...Expand to include additional state.
In App.js:
// create a new variable with the loggedin state
const value = { recipes, loggedin };
// pass the variable as the value
<RecipesContext.Provider value={value}>Update Recipes.js and RecipeDetail.js.
function Recipes({ addRecipe }) {
const { recipes, loggedin } = React.useContext(RecipesContext);function RecipeDetail({ deleteRecipe, editRecipe }) {
const { recipes, loggedin } = React.useContext(RecipesContext);Remove all props drilling for loggedin from App.js.
Continue with setLoggedin, addRecipe, deleteRecipe and editRecipe