import logoImg from "../assets/logo.jpg";
export default function Header() {
return (
<header id="main-header">
<div id="title">
<img src={logoImg} alt="logo image" />
<h1>ReactFood</h1>
</div>
<nav>
<button>Cart (0)</button>
</nav>
</header>
);
}
import { useState, useEffect } from "react";
export default function Meals() {
const [loadedMeals, setLoadedMeals] = useState([]);
useEffect(() => {
async function fetchMeals() {
const response = await fetch("http://localhost:3000/meals");
if (!response.ok) {
//...
}
const meals = await response.json();
setLoadedMeals(meals);
}
fetchMeals();
}, []); // 외부 속성이나 상태 혹은 렌더링 도중 변화를 가져올 만한 값을 사용하지 않았기 때문에 의존성이 없다.
// 외부 상태를 사용한 것은 setLoadedMeals인데 이는 리액트에서 자동으로 설정해준다.
return (
<ul id="meals">
{loadedMeals.map((meal) => (
<li key={meal.id}>{meal.name}</li>
))}
</ul>
);
}

export default function MealItem({ meal }) {
return (
<li className="meal-item" key={meal.id}>
<article>
<img src={`http://localhost:3000/${meal.image}`} alt={meal.name} />
<div>
<h3>{meal.name}</h3>
<p className="meal-item-price">{meal.price}</p>
<p className="meal-item-description">{meal.description}</p>
</div>
<p className="meal-item-actions">
<button>+ Add to Cart</button>
</p>
</article>
</li>
);
}
return (
<ul id="meals">
{loadedMeals.map((meal) => (
<MealItem key={meal.id} meal={meal} />
))}
</ul>
);

export const currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
import { currencyFormatter } from "../util/formatting.js";
export default function MealItem({ meal }) {
return (
<li className="meal-item" key={meal.id}>
<article>
<img src={`http://localhost:3000/${meal.image}`} alt={meal.name} />
<div>
<h3>{meal.name}</h3>
<p className="meal-item-price">
{currencyFormatter.format(meal.price)}
</p>
<p className="meal-item-description">{meal.description}</p>
</div>
<p className="meal-item-actions">
<button>+ Add to Cart</button>
</p>
</article>
</li>
);
}
export default function Button({ children, textOnly, className, ...props }) {
const cssClasses = textOnly
? `text-button ${className}`
: `button ${className}`;
return (
<button className={cssClasses} {...props}>
{children}
</button>
);
}
import logoImg from "../assets/logo.jpg";
import Button from "./UI/Button";
export default function Header() {
return (
<header id="main-header">
<div id="title">
<img src={logoImg} alt="logo image" />
<h1>ReactFood</h1>
</div>
<nav>
<Button textOnly>Cart (0)</Button>
</nav>
</header>
);
}
textOnly를 넣음으로써 리액트는 자동으로 해당 속성에 true를 전달.import { currencyFormatter } from "../util/formatting.js";
import Button from "./UI/Button.jsx";
export default function MealItem({ meal }) {
return (
<li className="meal-item" key={meal.id}>
<article>
<img src={`http://localhost:3000/${meal.image}`} alt={meal.name} />
<div>
<h3>{meal.name}</h3>
<p className="meal-item-price">
{currencyFormatter.format(meal.price)}
</p>
<p className="meal-item-description">{meal.description}</p>
</div>
<p className="meal-item-actions">
<Button>+ Add to Cart</Button>
</p>
</article>
</li>
);
}
textOnly를 추가하지 않음으로써 그냥 button 클래스가 입력되도록 함.
import { createContext, useReducer } from "react";
const CartContext = createContext({
items: [],
addItem: (item) => {},
removeItem: (id) => {},
});
function cartReducer(state, action) {
// 업데이트된 상태를 반환.
if (action.type === "ADD_ITEM") {
// 상태를 업데이트해서 음식 메뉴 항목을 더함.
const existingCartItemIndex = state.items.findIndex(
(item) => item.id === action.item.id
); // 이미 상태 항목에 같은 아이디를 갖는 음식이 있다면 해당 음식의 인덱스를 저장. -> 차후에 해당 음식을 오버라이딩하는데 이용.
const updatedItems = [...state.items]; // 이전 배열의 복사본
if (existingCartItemIndex > -1) {
// 없는 경우에는 -1을 리턴하기 때문에 해당 조건문은 해당 항목이 이미 배열에 있다는 의미이다.
const existingItem = state.items[existingCartItemIndex];
const updatedItem = {
...existingItem,
quantity: existingItem.quantity + 1,
};
updatedItems[existingCartItemIndex] = updatedItem; // 기존의 상품을 오버라이딩.
} else {
updatedItems.push({ ...action.item, quantity: 1 });
}
return { ...state, items: updatedItems };
}
if (action.type === "REMOVE_ITEM") {
// 상태에서 음식 메뉴 항목을 지움
const existingCartItemIndex = state.items.findIndex(
(item) => item.id === action.id
); // 이미 상태 항목에 같은 아이디를 갖는 음식이 있다면 해당 음식의 인덱스를 저장. -> 차후에 해당 음식을 지우는데 이용
const existingCartItem = state.items[existingCartItemIndex];
const updatedItems = [...state.items];
if (existingCartItem.quantity === 1) {
// 하나가 있다면 지웠을 때 장바구니에서 해당 음식이 지워져야함
updatedItems.splice(existingCartItemIndex, 1);
} else {
const updatedItem = {
...existingCartItem,
quantity: existingCartItem.quantity - 1,
};
updatedItems[existingCartItemIndex] = updatedItem; // 오버라이딩
}
return { ...state, items: updatedItems };
}
return state;
}
export function CartContextProvider({ children }) {
const [cart, dispatchCartAction] = useReducer(cartReducer, { items: [] }); // 리듀서 함수, 초기 상태값
const cartContext = {
items: cart.items,
addItem: addItem,
removeItem,
};
function addItem(item) {
dispatchCartAction({
type: "ADD_ITEM",
item: item, // item으로해도 된다.
});
}
function removeItem(id) {
dispatchCartAction({
type: "REMOVE_ITEM",
id,
});
}
console.log(cartContext);
return (
<CartContext.Provider value={cartContext}>{children}</CartContext.Provider>
);
}
export default CartContext;
우선 useReducer를 이용하여 더 복잡한 상태를 간단하게 다룰 수 있도록 한다. 이는 상태 관리 로직을 이 컴포넌트 함수 밖으로 내보내는 것이 쉬워진다.
useReducer(리듀서 함수, 초기 상태값)을 전달하여 상태 업데이트를 간단히 할 수 있도록 한다.
리듀서 함수 cartReduce
리듀서 함수는 state(상태)와 action(액션)을 입력받는다.
리듀서 함수의 액션에는 타입이라는 것이 있는데, 우리가 진행하는 프로젝트의 경우 장바구니에 음식 항목을 추가/제거 하는 것이다. 따라서 타입의 이름을 각각 ADD_ITEM, REMOVE_ITEM이라고 명명했다.
action.type === 'ADD_ITEM' 인 경우
findIndex를 통해 true(이미 존재한다면)값을 가진다면 해당 인덱스를 existingCartItemIndex에 저장한다.existingCartItem라고 선언한다.updatedItems)action.type === 'REMOVE_ITEM' 인 경우
CartContextProvider 안에 addItem,removeItem 함수를 정의한다.
각 함수들은 타입(ADD_ITEM, REMOVE_ITEM)을 가지고 있고 리듀서 함수에서 정의한 것처럼 item자체를 전달하거나 item의 id를 전달한다.
import Header from "./components/Header";
import Meals from "./components/Meals";
import { CartContextProvider } from "./store/CartContext";
function App() {
return (
<CartContextProvider>
<Header />
<Meals />
</CartContextProvider>
);
}
export default App;
import { currencyFormatter } from "../util/formatting.js";
import Button from "./UI/Button.jsx";
import CartContext from "../store/CartContext.jsx";
import { useContext } from "react";
export default function MealItem({ meal }) {
const cartCtx = useContext(CartContext);
function handleAddMealToCart() {
cartCtx.addItem(meal);
}
return (
<li className="meal-item" key={meal.id}>
<article>
<img src={`http://localhost:3000/${meal.image}`} alt={meal.name} />
<div>
<h3>{meal.name}</h3>
<p className="meal-item-price">
{currencyFormatter.format(meal.price)}
</p>
<p className="meal-item-description">{meal.description}</p>
</div>
<p className="meal-item-actions">
<Button onClick={handleAddMealToCart}>+ Add to Cart</Button>
</p>
</article>
</li>
);
}

import { useContext } from "react";
import logoImg from "../assets/logo.jpg";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
export default function Header() {
const cartCtx = useContext(CartContext);
// reduce는 배열을 하나의 값으로 줄여준다. 즉. 숫자 하나로 줄임.
// reduce(( 파생시키려는 새로운 값, 배열 )=>{}, 초기값)
const totalCartItems = cartCtx.items.reduce((totalNumberOfItems, item) => {
return totalNumberOfItems + item.quantity; // toalNumberOfItems에 현재 item의 quentity 속성을 확인하여 더함.
}, 0);
return (
<header id="main-header">
<div id="title">
<img src={logoImg} alt="logo image" />
<h1>ReactFood</h1>
</div>
<nav>
<Button textOnly>Cart ({totalCartItems})</Button>
</nav>
</header>
);
}

import { createPortal } from "react-dom";
import { useEffect, useRef } from "react";
export default function Modal({ children, open, className = "" }) {
const dialog = useRef();
useEffect(() => {
if (open) {
dialog.current.showModal();
}
}, [open]);
return createPortal(
<dialog ref={dialog} className={`modal ${className}`}>
{children}
</dialog>,
document.getElementById("modal")
);
}
// 모달과 관련된 컨텍스트
import { createContext, useState } from "react";
const UserProgressContext = createContext({
progress: "", // cart, checkout
showCart: () => {},
hideCart: () => {},
showCheckout: () => {},
hideCheckout: () => {},
});
export function UserProgressContextProvider({ children }) {
const [userProgress, setUserProgress] = useState("");
function showCart() {
setUserProgress("cart");
}
function hideCart() {
setUserProgress("");
}
function showCheckout() {
setUserProgress("checkout");
}
function hideCheckout() {
setUserProgress("");
}
const userProgressCtx = {
progress: userProgress,
showCart,
hideCart,
showCheckout,
hideCheckout,
};
return (
<UserProgressContext.Provider value={userProgressCtx}>
{children}
</UserProgressContext.Provider>
);
}
export default UserProgressContext;
import { useContext } from "react";
import Modal from "./UI/Modal";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
import { currencyFormatter } from "../util/formatting";
import UserProgressContext from "../store/UserProgressContext";
export default function Cart() {
const cartCtx = useContext(CartContext);
const userProgressCtx = useContext(UserProgressContext);
const cartTotal = cartCtx.items.reduce((totalPrice, item) => {
return totalPrice + item.quantity * item.price;
}, 0);
return (
// open={open}으로만 하지 않고 컨텍스트를 이용해서 해당 콘텍스트가 cart 이면 Cart 모달을 open할 것임을 전달
<Modal className="cart" open={userProgressCtx.progress === "cart"}>
<h2>Your Cart</h2>
<ul>
{cartCtx.items.map((item) => (
<li key={item.id}>
{item.name} - {item.quantity} x{" "}
{currencyFormatter.format(item.price)}
</li>
))}
</ul>
<p className="cart-total">{currencyFormatter.format(cartTotal)}</p>
<p className="modal-actions">
<Button textOnly>Close</Button>
<Button>Go to Checkout</Button>
</p>
</Modal>
);
}
import Header from "./components/Header";
import Meals from "./components/Meals";
import Cart from "./components/Cart";
import { CartContextProvider } from "./store/CartContext";
import { UserProgressContextProvider } from "./store/UserProgressContext";
function App() {
return (
<UserProgressContextProvider>
<CartContextProvider>
<Header />
<Meals />
<Cart />
</CartContextProvider>
</UserProgressContextProvider>
);
}
export default App;
import { useContext } from "react";
import logoImg from "../assets/logo.jpg";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
import UserProgressContext from "../store/UserProgressContext";
export default function Header() {
const cartCtx = useContext(CartContext);
const userProgressCtx = useContext(UserProgressContext);
// reduce는 배열을 하나의 값으로 줄여준다. 즉. 숫자 하나로 줄임.
// reduce(( 파생시키려는 새로운 값, 배열 )=>{}, 초기값)
const totalCartItems = cartCtx.items.reduce((totalNumberOfItems, item) => {
return totalNumberOfItems + item.quantity;
}, 0);
function handleShowCart() {
userProgressCtx.showCart();
}
return (
<header id="main-header">
<div id="title">
<img src={logoImg} alt="logo image" />
<h1>ReactFood</h1>
</div>
<nav>
<Button textOnly onClick={handleShowCart}>
Cart ({totalCartItems})
</Button>
</nav>
</header>
);
}
showCart() 가 동작하도록 함
import { useContext } from "react";
import Modal from "./UI/Modal";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
import { currencyFormatter } from "../util/formatting";
import UserProgressContext from "../store/UserProgressContext";
export default function Cart() {
const cartCtx = useContext(CartContext);
const userProgressCtx = useContext(UserProgressContext);
const cartTotal = cartCtx.items.reduce((totalPrice, item) => {
return totalPrice + item.quantity * item.price;
}, 0);
// close 함수 추가
function handleCloseCart() {
userProgressCtx.hideCart();
}
return (
<Modal className="cart" open={userProgressCtx.progress === "cart"}>
<h2>Your Cart</h2>
<ul>
{cartCtx.items.map((item) => (
<li key={item.id}>
{item.name} - {item.quantity} x{" "}
{currencyFormatter.format(item.price)}
</li>
))}
</ul>
<p className="cart-total">{currencyFormatter.format(cartTotal)}</p>
<p className="modal-actions">
<Button textOnly onClick={handleCloseCart}>
Close
</Button>
<Button>Go to Checkout</Button>
</p>
</Modal>
);
}
이렇게 해도 모달 닫기가 되지 않는 것을 알 수 있다. 이는 Modal.jsx에서 해당 모달을 닫기 위한 close함수가 적용되지 않았기 때문이다.
import { createPortal } from "react-dom";
import { useEffect, useRef } from "react";
export default function Modal({ children, open, className = "" }) {
const dialog = useRef();
useEffect(() => {
const modal = dialog.current; // 혹시나 다른 dialog를 참조할 수 있으므로 현재 dialog를 별도의 상수에 저장하여 컨트롤
if (open) {
modal.showModal();
}
// 모달 close에 관한 코드 작성 필요.
return () => modal.close(); // cleanup은 시점상으로는 effect 함수보다 더 나중에 실행된다.
// cleanup함수는 open값이 미래에 변하는 때에만 실행되기 때문이다.
}, [open]);
return createPortal(
<dialog ref={dialog} className={`modal ${className}`}>
{children}
</dialog>,
document.getElementById("modal")
);
}

import { currencyFormatter } from "../util/formatting";
export default function CartItem({
name,
quantity,
price,
onIncrease,
onDecrease,
}) {
return (
<li className="cart-item" key={name}>
<p>
{name} - {quantity} X {currencyFormatter.format(price)}
</p>
<p className="cart-item-actions">
<button onClick={onDecrease}>-</button>
<span>{quantity}</span>
<button onClick={onIncrease}>+</button>
</p>
</li>
);
}
import { useContext } from "react";
import Modal from "./UI/Modal";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
import { currencyFormatter } from "../util/formatting";
import UserProgressContext from "../store/UserProgressContext";
import CartItem from "./CartItem";
export default function Cart() {
const cartCtx = useContext(CartContext);
const userProgressCtx = useContext(UserProgressContext);
const cartTotal = cartCtx.items.reduce((totalPrice, item) => {
return totalPrice + item.quantity * item.price;
}, 0);
function handleCloseCart() {
userProgressCtx.hideCart();
}
return (
<Modal className="cart" open={userProgressCtx.progress === "cart"}>
<h2>Your Cart</h2>
<ul>
{cartCtx.items.map((item) => (
<CartItem
key={item.id}
{...item}
onIncrease={() => cartCtx.addItem(item)}
onDecrease={() => cartCtx.removeItem(item.id)}
/>
))}
</ul>
<p className="cart-total">{currencyFormatter.format(cartTotal)}</p>
<p className="modal-actions">
<Button textOnly onClick={handleCloseCart}>
Close
</Button>
<Button>Go to Checkout</Button>
</p>
</Modal>
);
}

import { useContext } from "react";
import { currencyFormatter } from "../util/formatting";
import Modal from "./UI/Modal";
import Input from "./UI/Input";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
import UserProgressContext from "../store/UserProgressContext";
export default function Checkout({}) {
const cartCtx = useContext(CartContext);
const userProgressCtx = useContext(UserProgressContext);
const cartTotal = cartCtx.items.reduce((totalPrice, item) => {
totalPrice + item.quantity * item.price;
}, 0);
function handleCloseCheckout() {
userProgressCtx.hideCheckout();
}
return (
<Modal
open={userProgressCtx.progress === "checkout"}
onClose={handleCloseCheckout}
>
<form>
<h2>Checkout</h2>
<p>Total Amount: {currencyFormatter.format(cartTotal)}</p>
<Input label="Full Name" id="full-name" type="text" />
<Input label="E-mail Address" id="email" type="email" />
<Input label="Street" id="street" type="text" />
<div className="control-row">
<Input label="Postal Code" id="postal-code" type="text" />
<Input label="City" id="city" type="text" />
</div>
<p className="modal-actions">
<Button type="button" onClick={handleCloseCheckout} textOnly>
Close
</Button>
<Button>Submit Order</Button>
</p>
</form>
</Modal>
);
}
export default function Input({ label, id, ...props }) {
return (
<p className="control">
<label htmlFor={id}>{label}</label>
<input id={id} name={id} {...props} required />
</p>
);
}
import Header from "./components/Header";
import Meals from "./components/Meals";
import Cart from "./components/Cart";
import Checkout from "./components/Checkout";
import { CartContextProvider } from "./store/CartContext";
import { UserProgressContextProvider } from "./store/UserProgressContext";
function App() {
return (
<UserProgressContextProvider>
<CartContextProvider>
<Header />
<Meals />
<Cart />
<Checkout />
</CartContextProvider>
</UserProgressContextProvider>
);
}
export default App;
// components/UI/Modal.jsx
import { createPortal } from "react-dom";
import { useEffect, useRef } from "react";
export default function Modal({ children, open, onClose, className = "" }) {
const dialog = useRef();
useEffect(() => {
const modal = dialog.current;
if (open) {
modal.showModal();
}
return () => modal.close();
}, [open]);
return createPortal(
<dialog ref={dialog} className={`modal ${className}`} onClose={onClose}>
{children}
</dialog>,
document.getElementById("modal")
);
}
// Cart.jsx
import { useContext } from "react";
import Modal from "./UI/Modal";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
import { currencyFormatter } from "../util/formatting";
import UserProgressContext from "../store/UserProgressContext";
import CartItem from "./CartItem";
export default function Cart() {
const cartCtx = useContext(CartContext);
const userProgressCtx = useContext(UserProgressContext);
const cartTotal = cartCtx.items.reduce((totalPrice, item) => {
return totalPrice + item.quantity * item.price;
}, 0);
function handleCloseCart() {
userProgressCtx.hideCart();
}
function handleGoToCheckout() {
userProgressCtx.showCheckout();
}
return (
// Modal에 onClose 속성을 전달하여 만약 컨텍스트의 progress 속성이 cart이면, handleCloseCart 함수를 실행하고
// progress 속성이 cart가 아니면 해당 속성을 null로 설정. -> 무조건 모달이 닫힘을 방지하여 Checkout으로 넘어갈 수 있도록 함.
<Modal
className="cart"
open={userProgressCtx.progress === "cart"}
onClose={userProgressCtx.progress === "cart" ? handleCloseCart : null}
>
<h2>Your Cart</h2>
<ul>
{cartCtx.items.map((item) => (
<CartItem
key={item.id}
{...item}
onIncrease={() => cartCtx.addItem(item)}
onDecrease={() => cartCtx.removeItem(item.id)}
/>
))}
</ul>
<p className="cart-total">{currencyFormatter.format(cartTotal)}</p>
<p className="modal-actions">
<Button textOnly onClick={handleCloseCart}>
Close
</Button>
{cartCtx.items.length > 0 && (
<Button onClick={handleGoToCheckout}>Go to Checkout</Button>
)}
</p>
</Modal>
);
}
import { useContext } from "react";
import { currencyFormatter } from "../util/formatting";
import Modal from "./UI/Modal";
import Input from "./UI/Input";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
import UserProgressContext from "../store/UserProgressContext";
export default function Checkout({}) {
const cartCtx = useContext(CartContext);
const userProgressCtx = useContext(UserProgressContext);
const cartTotal = cartCtx.items.reduce((totalPrice, item) => {
totalPrice + item.quantity * item.price;
}, 0);
function handleCloseCheckout() {
userProgressCtx.hideCheckout();
}
function handleSubmit(event) {
event.preventDefault();
const fd = new FormData(event.target); // 입력에 name이라는 속성이 있는데 다양한 Input 필드에서 이름에 따라 구분하고 값을 추출할 수있다.
const customerData = Object.fromEntries(fd.entries()); // 객체를 받는다. { email : test@example.com }
fetch("http://localhost:3000/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
order: {
items: cartCtx.items,
customer: customerData,
},
}),
});
}
return (
<Modal
open={userProgressCtx.progress === "checkout"}
onClose={handleCloseCheckout}
>
<form onSubmit={handleSubmit}>
<h2>Checkout</h2>
<p>Total Amount: {currencyFormatter.format(cartTotal)}</p>
<Input label="Full Name" id="name" type="text" />
<Input label="E-mail Address" id="email" type="email" />
<Input label="Street" id="street" type="text" />
<div className="control-row">
<Input label="Postal Code" id="postal-code" type="text" />
<Input label="City" id="city" type="text" />
</div>
<p className="modal-actions">
<Button type="button" onClick={handleCloseCheckout} textOnly>
Close
</Button>
<Button>Submit Order</Button>
</p>
</form>
</Modal>
);
}


import { useState, useEffect, useCallback } from "react";
async function sendHttpRequest(url, config) {
// 요청을 보내는 업무 전반을 담당
const response = await fetch(url, config);
const resData = await response.json();
if (!response.ok) {
throw new Error(resData.message || "Http 요청을 보내지 못했습니다."); // backend/app.js에서 responseData의 json에 에러메시지가 있다.
}
return resData;
}
// http 요청을 할 커스텀 훅 작성
export default function useHttp(url, config, initialData) {
const [data, setData] = useState(initialData);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
const sendRequest = useCallback(
async function sendRequest() {
// 요청 상태에 따라 상태를 업데이트
setIsLoading(true);
try {
const resData = await sendHttpRequest(url, config);
setData(resData);
} catch (error) {
setError(error.message || "문제가 발생했습니다.");
}
setIsLoading(false);
},
[url, config] // 이 둘 중 하나라도 변하면 다시 진행해야한다.
);
useEffect(() => {
// GET 요청이 보내져야 하는 시점은 이 훅을 포함한 컴포넌트가 렌더링될 때이다.
// 만약 GET이 아닌 다른 요청 메서드를 사용한다면 항상 sendRequest()를 보낼 필요가 없다.
// (+) GET의 경우 따로 method를 설정하지 않아도 default가 GET이므로 fetch 요청을 보낼 때. 따로 config를 작성하지 않을 수 있다.
// 따라서 !config.method, !config 를 조건문에 채워넣음으로써 config를 설정하지 않는 GET 요청도 해당 조건문에 들어갈 수 있도록 설정
if ((config && (config.method === "GET" || !config.method)) || !config) {
sendRequest();
}
}, [sendRequest, config]); // 무한 루프를 방지하기 위해 sendRequest를 useCallback으로 감싼다.
return {
data,
isLoading,
error,
sendRequest, // GET이 아닌 다른 메서드(POST)일 때 언제든 직접 sendRequest를 보낼 수 있도록 함.
};
}
useHttp 커스텀 훅을 작성하고 해당 훅 안에 sendRequest 함수를 작성한다.sendRequestsendRequest 함수는 상태(로딩, 에러, 데이터)를 업데이트하면서 sendHttpRequest 함수를 동작시킨다. 이때, sendHttpRequest 함수는 백엔드에 요청을 보내는 역할만을 수행한다. → 가독성 측면에서 좋음sendRequest 함수는 비동기식이므로 sendHttpRequest 앞에 await 키워드를 추가할 필요가 있다.useEffectsendRequest 함수는 http 요청의 config가 바뀌는 경우에 다시 실행할 필요가 있으므로 useEffect를 이용하였다.sendRequest)를 사용하기 때문에 의존성에 sendRequest를 추가하고, 함수를 의존성에 추가하는 것이므로 useCallback으로 sendRequest 함수를 감싸 무한 루프에 빠지지 않도록 한다.config에 대한 조건문을 달아야한다.sendRequest 메서드를 출력하여 fetch할 것이므로 sendRequest 함수도 리턴한다.import useHttp from "../hooks/useHttp";
import MealItem from "./MealItem";
const requestConfig = {};
export default function Meals() {
const {
data: loadedMeals,
isLoading,
error,
} = useHttp("http://localhost:3000/meals", requestConfig, []);
// 그냥 {}으로 config를 설정하지만 해당 객체는 계속해서 재생성되는 객체이다.
// 따라서 해당 컴포넌트 밖에서 requestConfig를 설정하여 빈 객체를 전달
console.log(loadedMeals);
if (isLoading) {
return <p>Fetching Meals...</p>;
}
return (
<ul id="meals">
{loadedMeals.map((meal) => (
<MealItem key={meal.id} meal={meal} />
))}
</ul>
);
}
useHttp를 추가했다. GET 메서드를 사용하므로 별도의 config를 제출하진 않았으며 initialData로 빈 배열을 전달하여 커스텀 훅의 데이터 상태에 초기값을 전달한다.useHttp('url', {}, [])로만 fetch한다면 {}는 빈 객체이고 커스텀 훅의 effect 함수의 의존성에 따라 계속해서 재생성될 것이다 → 무한 루프 진행requestConfig 를 설정하여 전달한다.import useHttp from "../hooks/useHttp";
import MealItem from "./MealItem";
import Error from "./Error";
const requestConfig = {};
export default function Meals() {
const {
data: loadedMeals,
isLoading,
error,
} = useHttp("http://localhost:3000/meals", requestConfig, []);
if (isLoading) {
return <p className="center">Fetching Meals...</p>;
}
if (error) {
return <Error title="메뉴를 불러오는데 실패했습니다." message={error} />;
}
return (
<ul id="meals">
{loadedMeals.map((meal) => (
<MealItem key={meal.id} meal={meal} />
))}
</ul>
);
}

export default function Error({ title, message }) {
return (
<div className="error">
<h2>{title}</h2>
<p>{message}</p>
</div>
);
}

import { useContext } from "react";
import { currencyFormatter } from "../util/formatting";
import Modal from "./UI/Modal";
import Input from "./UI/Input";
import Button from "./UI/Button";
import CartContext from "../store/CartContext";
import UserProgressContext from "../store/UserProgressContext";
import useHttp from "../hooks/useHttp";
import Error from "./Error";
const requestConfig = {
method: "POST",
headers: {
"Content-Type": "application/json",
},
};
export default function Checkout({}) {
const cartCtx = useContext(CartContext);
const userProgressCtx = useContext(UserProgressContext);
const {
data,
isLoading: isSending,
error,
sendRequest,
clearData,
} = useHttp("http://localhost:3000/orders", requestConfig);
const cartTotal = cartCtx.items.reduce((totalPrice, item) => {
return totalPrice + item.quantity * item.price;
}, 0);
function handleCloseCheckout() {
userProgressCtx.hideCheckout();
}
function handleFinish() {
userProgressCtx.hideCheckout();
cartCtx.clearCart();
clearData();
}
function handleSubmit(event) {
event.preventDefault();
const fd = new FormData(event.target); // 입력에 name이라는 속성이 있는데 다양한 Input 필드에서 이름에 따라 구분하고 값을 추출할 수있다.
const customerData = Object.fromEntries(fd.entries()); // 객체를 받는다. { email : test@example.com }
sendRequest(
JSON.stringify({
order: {
items: cartCtx.items,
customer: customerData,
},
})
);
}
let actions = (
<>
<Button type="button" onClick={handleCloseCheckout} textOnly>
Close
</Button>
<Button>Submit Order</Button>
</>
);
if (isSending) {
actions = <span>데이터를 보내는 중입니다...</span>;
}
if (data && !error) {
return (
<Modal
open={userProgressCtx.progress === "checkout"}
onClose={handleCloseCheckout}
>
<h2>주문 성공!</h2>
<p>주문이 정상적으로 처리되었습니다.</p>
<p>주문에 대한 상세 내용을 이메일로 보내드리겠습니다.</p>
<p className="modal-actions">
<Button onClick={handleFinish}>Okay</Button>
</p>
</Modal>
);
}
return (
<Modal
open={userProgressCtx.progress === "checkout"}
onClose={handleCloseCheckout}
>
<form onSubmit={handleSubmit}>
<h2>Checkout</h2>
<p>Total Amount: {currencyFormatter.format(cartTotal)}</p>
<Input label="Full Name" id="name" type="text" />
<Input label="E-mail Address" id="email" type="email" />
<Input label="Street" id="street" type="text" />
<div className="control-row">
<Input label="Postal Code" id="postal-code" type="text" />
<Input label="City" id="city" type="text" />
</div>
{error && (
<Error title="주문을 전송하는데 실패했습니다." message={error} />
)}
<p className="modal-actions">{actions}</p>
</form>
</Modal>
);
}
import { createContext, useReducer } from "react";
const CartContext = createContext({
items: [],
addItem: (item) => {},
removeItem: (id) => {},
clearCart: () => {},
});
function cartReducer(state, action) {
// 업데이트된 상태를 반환.
if (action.type === "ADD_ITEM") {
// 상태를 업데이트해서 음식 메뉴 항목을 더함.
const existingCartItemIndex = state.items.findIndex(
(item) => item.id === action.item.id
); // 이미 상태 항목에 같은 아이디를 갖는 음식이 있다면 해당 음식의 인덱스를 저장. -> 차후에 해당 음식을 오버라이딩하는데 이용.
const updatedItems = [...state.items]; // 이전 배열의 복사본
if (existingCartItemIndex > -1) {
// 없는 경우에는 -1을 리턴하기 때문에 해당 조건문은 해당 항목이 이미 배열에 있다는 의미이다.
const existingItem = state.items[existingCartItemIndex];
const updatedItem = {
...existingItem,
quantity: existingItem.quantity + 1,
};
updatedItems[existingCartItemIndex] = updatedItem; // 기존의 상품을 오버라이딩.
} else {
updatedItems.push({ ...action.item, quantity: 1 });
}
return { ...state, items: updatedItems };
}
if (action.type === "REMOVE_ITEM") {
// 상태에서 음식 메뉴 항목을 지움
const existingCartItemIndex = state.items.findIndex(
(item) => item.id === action.id
); // 이미 상태 항목에 같은 아이디를 갖는 음식이 있다면 해당 음식의 인덱스를 저장. -> 차후에 해당 음식을 지우는데 이용
const existingCartItem = state.items[existingCartItemIndex];
const updatedItems = [...state.items];
if (existingCartItem.quantity === 1) {
// 하나가 있다면 지웠을 때 장바구니에서 해당 음식이 지워져야함
updatedItems.splice(existingCartItemIndex, 1);
} else {
const updatedItem = {
...existingCartItem,
quantity: existingCartItem.quantity - 1,
};
updatedItems[existingCartItemIndex] = updatedItem; // 오버라이딩
}
return { ...state, items: updatedItems };
}
if (action.type === "CLEAR_CART") {
return { ...state, items: [] };
}
return state;
}
export function CartContextProvider({ children }) {
// 더 복잡한 상태를 간단하게 다룰 수 있도록 함. 상태 관리 로직을 이 컴포넌트 함수 밖으로 내보내는 것이 쉬워짐.
const [cart, dispatchCartAction] = useReducer(cartReducer, { items: [] }); // 리듀서 함수, 초기 상태값
const cartContext = {
items: cart.items,
addItem: addItem,
removeItem,
clearCart,
};
function addItem(item) {
dispatchCartAction({
type: "ADD_ITEM",
item: item, // item으로해도 된다.
});
}
function removeItem(id) {
dispatchCartAction({
type: "REMOVE_ITEM",
id,
});
}
function clearCart() {
dispatchCartAction({
type: "CLEAR_CART",
});
}
console.log(cartContext);
return (
<CartContext.Provider value={cartContext}>{children}</CartContext.Provider>
);
}
export default CartContext;
import { useState, useEffect, useCallback } from "react";
async function sendHttpRequest(url, config) {
// 요청을 보내는 업무 전반을 담당
const response = await fetch(url, config);
const resData = await response.json();
if (!response.ok) {
throw new Error(resData.message || "Http 요청을 보내지 못했습니다."); // backend/app.js에서 responseData의 json에 에러메시지가 있다.
}
return resData;
}
// http 요청을 할 커스텀 훅 작성
export default function useHttp(url, config, initialData) {
const [data, setData] = useState(initialData);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
function clearData() {
setData(initialData);
}
const sendRequest = useCallback(
async function sendRequest(data) {
// 요청 상태에 따라 상태를 업데이트
setIsLoading(true);
try {
const resData = await sendHttpRequest(url, { ...config, body: data });
setData(resData);
} catch (error) {
setError(error.message || "문제가 발생했습니다.");
}
setIsLoading(false);
},
[url, config] // 이 둘 중 하나라도 변하면 다시 진행해야한다.
);
useEffect(() => {
// GET 요청이 보내져야 하는 시점은 이 훅을 포함한 컴포넌트가 렌더링될 때이다.
// 만약 GET이 아닌 다른 요청 메서드를 사용한다면 항상 sendRequest()를 보낼 필요가 없다.
// (+) GET의 경우 따로 method를 설정하지 않아도 default가 GET이므로 fetch 요청을 보낼 때. 따로 config를 작성하지 않을 수 있다.
// 따라서 !config.method, !config 를 조건문에 채워넣음으로써 config를 설정하지 않는 GET 요청도 해당 조건문에 들어갈 수 있도록 설정
if ((config && (config.method === "GET" || !config.method)) || !config) {
sendRequest();
}
}, [sendRequest, config]); // 무한 루프를 방지하기 위해 sendRequest를 useCallback으로 감싼다.
return {
data,
isLoading,
error,
sendRequest, // GET이 아닌 다른 메서드(POST)일 때 언제든 직접 sendRequest를 보낼 수 있도록 함.
clearData,
};
}


🔗 Commit History
🔗 스스로 만든 프로젝트
우선 나는 Reducer를 사용하여 상태를 업데이트하지 못했다.
useState를 이용해 상태를 업데이트 했다. 그러다보니 장바구니 모달에서 음식을 추가/제거를 할 때마다 상태가 업데이트되어 UI도 계속 변경되었다.useState로만 구현하기에는 너무 어렵고 복잡했다.오류와 로딩은 모든 컴포넌트와 모든 동작들을 구현한 뒤에 작성하자.