Customize the backend and client work with your own content to create a full stack website. Edit the schema to conform to you content. Edit the client to display any content that you add.
- CRUD operations are optional
- CSS and design are optional
You should have read and stepped through the useState and useEffect documentation. Use Code Sandbox to examine the code samples.
Read:
Clone this repo into your projects folder and cd into it.
Remove the .git
directory with rm -rf .git
.
- cd into the backend,
- initialize it as a git repo,
- NPM install all dependencies,
- start Docker and run
docker run --name recipes-mongo -dit -p 27017:27017 --rm mongo:4.4.1
- start the backend:
npm run dev
- test by visiting localhost on port 3456.
Note the scripts in package.json:
"scripts": {
"start": "NODE_ENV=production node server.js",
"dev": "NODE_ENV=development nodemon server.js"
},
Note the following in server.js:
if (process.env.NODE_ENV === "production") {
require("dotenv").config();
}
Go to MongoDb and sign in to your account. Find the Cluster you created and click on 'Connect' to get the connection string.
Replace the database variable in backend/.env.sample
with your own and rename the file to .env
. Review the Database Access and Network Access settings on MongoDb. Check to ensure that your IP address settings are current or set to 'all.'
Ensure that your local DB is running:
docker run --name recipes-mongo -dit -p 27017:27017 --rm mongo:4.4.1
npm install and run npm run dev
to test. You should see the vanilla js site we constructed. Be sure to test the /api/recipes
endpoint as well.
Note: install a JSON formatter for your browser.
Chrome or use Firefox Developer Edition.
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.js
in 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.
To use the Heroku app:
"proxy": "https://recipes-do-not-delete.herokuapp.com/",
To use your local db (make sure you start the backend using npm run dev
)
"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 some basic CSS:
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<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;
}
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;
Demo - the problem with not adding an empty dependency array:
React.useEffect(() => {
console.log(" useEffect running ");
fetch(`/api/recipes`)
.then((response) => response.json())
.then((data) => setRecipes(data));
});
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 and test.
Scaffold the Recipe component:
import React from "react";
function Recipe({ recipe }) {
const { title, year, description, image, _id } = recipe;
return (
<summary>
<img src={`img/${image}`} alt={title} />
<h3>
<a href={_id}>{title}</a>
</h3>
<p>{description}</p>
<small>Published: {year}</small>
</summary>
);
}
export default Recipe;
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;
App.js
imports and renders Recipes.js
:
import React from "react";
import Recipes from "./Recipes";
function App() {
const [recipes, setRecipes] = React.useState([]);
React.useEffect(() => {
fetch(`/api/recipes`)
.then((response) => response.json())
.then((data) => setRecipes(data));
}, []);
return (
<main>
<Recipes recipes={recipes} />
</main>
);
}
export default App;
Demo - in Recipe.js:
{ process.env.NODE_ENV === "production" ? "prod" : "dev" }
<small>
You are running this application in <b>{process.env.NODE_ENV}</b> mode.
</small>;
Up until this point, you have dealt with simple projects that do not require transitioning from one view to another. You 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 cd'd into the client directory before installing React related packages.
npm install the latest version of react router and import the router into App.
npm i react-router-dom
Begin 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) => response.json())
.then((data) => setRecipes(data));
}, []);
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 { Link } from "react-router-dom";
function Recipe({ recipe }) {
const { title, year, description, image, _id } = recipe;
return (
<summary>
<img src={`img/${image}`} alt={title} />
<h3>
<Link to={_id}>{title}</Link>
</h3>
<p>{description}</p>
<small>Published: {year}</small>
</summary>
);
}
export default Recipe;
Check for browser refresh on the new route by watching the console.
Demo - note that the component renders twice. Try using an object instead of an array to initialize state:
const [recipes, setRecipes] = React.useState({});
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) => response.json())
.then((data) => setRecipes(data));
}, []);
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]);
// const thisRecipe = { ...currRecipe[0] };
// console.log(" thisRecipe ", thisRecipe);
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;
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) {
// call useState, "reserve piece of state"
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>
);
}
Create a hooks directory in src, move the custom hook with the changes below into it, and export it.
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, error } = useFetch(`/api/recipes`);
- Change the recipes route to pass data the the recipes component:
<Route path="/" element={<Recipes recipes={recipes} />} />
- Finally, add the loading and error returns we used in the json placeholder example:
if (loading === true) {
return <p>Loading</p>;
}
if (error) {
return <p>{error}</p>;
}
Review: destructuring on MDN. Note the ability to re-assign variable names and how they differ for Arrays and Objects.
After resetting data to recipes ( data: recipes
), 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
:
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 as Router, Route, Routes } from "react-router-dom";
import Recipes from "./Recipes";
import RecipeDetail from "./RecipeDetail";
import Nav from "./Nav";
import { useFetch } from "./hooks/api";
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;
npm i styled-components
Install styled components and create some css to support the new element:
import styled from "styled-components";
const NavStyles = 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;
}
`;
An element shouldn’t set its width, margin, height and color. These attributes should be set by its parent(s).
Remove the button styles in Nav and review CSs variables by using a variable to set the background color of Nav:
const NavStyles = 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/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: --btn-bg: var(--btn-color, #bada55);
uses a variable to set a variable and provides a fallback.
Import it into Nav import Button from "./button/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 NavStyles = 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 proably want to store our color palette at a higher level. Add to index.css:
:root {
--blue-dark: #046e9d;
}
In Nav:
--btn-color: var(--blue-dark);
This is the beginning of our standalone Button component.
Also: const [loggedin, setLoggedin] = useToggle(false);
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.js to 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 the form:
import React from "react";
import Button from "./button/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);
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 for handling state change for inputs vs. how we accomplished the same task in previous classes. (Examine the console output.)
We are using computed property names.
Review Object assignment and computed values:
var testObj = {};
// dot assignment
testObj.age = 80;
console.log(testObj);
var myKey = "name";
var myValue = "Daniel";
testObj = {};
// bracket assignment
testObj[myKey] = myValue;
console.log(testObj);
// Computed Property Names
// Before: 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("color", "purple"); //?
Test the button.
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 useApi() {
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 useApi;
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) => 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;
Add an addRecipe function to App.js and props drill it down to Recipes
:
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.
Props 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, 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.
Demo - backend validation:
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`);
...
const deleteRecipe = (recipeId) => {
console.log("recipeId:", recipeId);
del(`/api/recipes/${recipeId}`).then(window.location.replace("/"));
};
...
<Route
path="/:recipeId"
element={
<RecipeDetail
recipes={recipes}
deleteRecipe={deleteRecipe}
loggedin={loggedin}
/>
}
/>
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`);
...
const editRecipe = (updatedRecipe) => {
console.log(updatedRecipe);
put(`/api/recipes/${updatedRecipe._id}`, updatedRecipe).then(
get("/api/recipes").then((data) => {
setRecipes(data);
})
);
};
...
<RecipeDetail
recipes={recipes}
deleteRecipe={deleteRecipe}
loggedin={loggedin}
editRecipe={editRecipe}
/>
New src/FormEditRecipe.js
:
import React from "react";
import Button from "./button/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";
...
function RecipeDetail({ recipes, loggedin, deleteRecipe, editRecipe }) {
...
<FormEditRecipe thisRecipe={thisRecipe} editRecipe={editRecipe} />
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);
...
const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = function (app) {
app.use(
createProxyMiddleware(["/api", "/img"], {
target: "http://localhost:3456/",
})
);
};
// "proxy": "https://recipes-do-not-delete.herokuapp.com/",
// "proxy": "http://localhost:3456/",
We could run npm run build
in the client folder and use that or use a Heroku postbuild script.
In the script section of server's package.json add a script to:
- turn production mode off (you can't run npm build in the production environment)
- do an npm install and build (prefixing client)
"heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix client && npm run build --prefix client"
server.js
needs to be set up to serve the build.
Make sure the following goes after all the routes:
if (process.env.NODE_ENV === "production") {
// set static folder
app.use(express.static("client/build"));
app.get("*", (req, res) => {
res.sendFile(path.resolve(__dirname, "client", "build", "index.html"));
});
}
The path.resolve has to have four arguments.
Because we're using Node's built in path method be sure to require it (at the top of server.js):
const path = require('path');
And also... be sure to remove the old express.static middleware (it is now in the 'else' statement above).
You can also add your database URI to Config Vars
in the Heroku Settings
for your project.