* React Hooks를 사용한 상태 관리 방법
- 컴포넌트끼리 상태 주고받기
결과 화면
파일(컴포넌트) 구조
<App> 루트 컴포넌트
- <Nav /> : 내비게이션 바
- "상품리스트" , "장바구니" 버튼
- <ItemListContainer /> 또는 <ShoppingCart> : 물건 목록이 나열된 쇼핑몰 메인 화면 또는 장바구니 화면 (Pages)
- <ItemListContainer /> : <Items /> map으로 렌더링
- <Item /> : 각 물건의 이름, 가격, '장바구니 담기' 버튼 등
- <ShoppingCart> : <CartItem /> map으로 렌더링
- <CartItem /> : 각 물건의 이름, 가격, '장바구니에서 삭제하기' 버튼 등
- <OrderSummary /> : 주문 합계, 총 아이템 개수 등
- <ItemListContainer /> : <Items /> map으로 렌더링
상태
상품 목록 (items)
: 개별 상품에 대한 정보를 담은 객체 (배열의 형태)
{
"id": 1,
"name": "노른자 분리기",
"img": "../images/egg.png",
"price": 9900
}
장바구니 목록(cartItems)
: 장바구니에는 상품 아이디와, 수량을 담은 객체 (배열의 형태)
{
"itemId": 1,
"quantity": 1
}
체크된 상품 목록 (checkedItems)
: 장바구니에서 체크된 상품 (cartItems에서 map 함수로 itemId 정보만 담긴 객체 (배열의 형태)
{
"itemId": 1
}
데이터 흐름
쇼핑몰 애플리케이션 주요기능 구현
1. 상단 내비게이션 바에 상품 개수 업데이트
장바구니에 추가하는 상품 개수의 변동이 생길 때마다, 상단 내비게이션 바에 상품 개수가 업데이트되도록 구현한다.
App.js
- Nav
- Nav에 cartItems 상태를 내려보낸다.
- 이는 총 상품 목록 개수(배열의 길이)를 통한 "현재 담긴 상품의 수"를 구현하기 위함
- ItemListContainer와 ShoppingCart
- 총 상품 목록인 items와 장바구니에 담긴 상품 목록 cartItems, 그에 따른 상태변경 함수 setCartItems를 내려보낸다.
import React, { useState } from 'react';
import Nav from './components/Nav';
import ItemListContainer from './pages/ItemListContainer';
import './App.css';
import './variables.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import ShoppingCart from './pages/ShoppingCart';
import { initialState } from './assets/state';
function App() {
const [items, setItems] = useState(initialState.items);
const [cartItems, setCartItems] = useState(initialState.cartItems);
return (
<Router>
<Nav cartItems={cartItems} />
<Routes>
<Route path="/"
element={<ItemListContainer
cartItems={cartItems} setCartItems={setCartItems} items={items} />} />
<Route path="/shoppingcart"
element={<ShoppingCart
cartItems={cartItems} setCartItems={setCartItems} items={items} />}
/>
</Routes>
<img id="logo_foot" alt="logo_foot"
src={`${process.env.PUBLIC_URL}/mall.png`}/> <!--🟣 설명(1)-->
</Router>
);
}
🟣 설명 (1) : ${process.env.PUBLIC_URL}
요약: 수많은 이미지를 상대경로로 import 해오지 않고 절대경로로 간편하게 쓰기 위함!!!
- ${process.env.PUBLIC_URL}은 React 애플리케이션에서 공개 URL(public URL)을 참조하는 방법이다.
- 이 경로는 일반적으로 웹 서버에서 정적 파일을 제공하는 위치이다.
- 정적 파일은 HTML을 제외하고 웹 페이지를 렌더링할 때 필요한 추가 파일들이다.
- 웹 서버에 미리 저장되어 있어 css, image 파일 같이 컨텐츠가 고정되어있고 사용자의 요청에 따라 변하지 않는다.
- React 애플리케이션의 경우, process.env.PUBLIC_URL은 public 폴더를 가리키는 것이 일반적이다.
- public 폴더는 React 애플리케이션의 루트 디렉토리에 위치하며,
- HTML, 이미지, 폰트 및 기타 정적 파일과 같은 애플리케이션에서 사용되는 파일들을 저장하는 곳이다.
- process.env.PUBLIC_URL을 사용하여 이러한 정적 파일을 참조하면, 파일 경로를 수정하지 않고도 애플리케이션을 다른 경로에서 호스팅할 수 있다.
- React 애플리케이션은 브라우저에서 실행되는 클라이언트 측 JavaScript 코드로, HTML, CSS 및 JavaScript 파일 등을 포함하는 정적 파일이 웹 서버에서 제공된다.
- 이 정적 파일들은 애플리케이션이 호스팅되는 경로에 따라 서버에서 서비스된다.
- process.env.PUBLIC_URL은 애플리케이션이 호스팅되는 경로의 루트 URL을 참조한다.
- 이것을 ${process.env.PUBLIC_URL}와 함께 사용하면, 브라우저에서 애플리케이션을 로드할 때 애플리케이션이 서비스되는 루트 URL을 동적으로 가져와서 사용할 수 있다.
- 즉, 이를 사용하여 상대 경로 대신에 절대 경로를 사용하여 파일을 참조할 수 있다.
- 이렇게 하면 애플리케이션이 다른 경로에서 호스팅되더라도 파일 경로를 수정할 필요가 없다.
- process.env.PUBLIC_URL은 React 애플리케이션이 서비스되는 경로를 가리킨다.
2. 장바구니에 추가
메인 화면에서 [장바구니 담기] 버튼을 누르면, 장바구니에 해당 상품이 추가되도록 구현한다.
Item.js
부모인 메인화면(ItemListContainer.js) 컴포넌트에서 map함수로 하나하나 렌더링된 item 컴포넌트
import React from 'react'
export default function Item({ item, handleClick }) {
return (
<div key={item.id} className="item">
<img className="item-img" src={item.img} alt={item.name}></img>
<span className="item-name">{item.name}</span>
<span className="item-price">{item.price}</span>
<button className="item-button" onClick={() => handleClick(item.id)}>장바구니 담기</button>
</div>
)
}
// '장바구니 담기'를 클릭하면 handleClick 함수에 해당 item의 item.id를 인자로 전달하여 호출한다.
메인 화면 (ItemListContainer.js)
import React from 'react';
import Item from '../components/Item';
function ItemListContainer({ items, cartItems, setCartItems }) {
const handleClick = (id) => {
const index = cartItems.findIndex(item => item.itemId === id); // 일단 해당 아이템이 장바구니에 담겨있는지 확인
if (index === -1) { // cartItems에 해당 id가 없으면,
setCartItems([...cartItems, {itemId: id, quantity: 1}]);
} else {
const newCartItems = [...cartItems]; // 🟣 설명(1)
newCartItems[index].quantity++ // 상태의 데이터를 직접 바꾸면 안 되기 때문에 newCartItems에 배열을 복사하는 것
setCartItems(newCartItems)
}}
return (
<div id="item-list-container">
<div id="item-list-body">
<div id="item-list-title">쓸모없는 선물 모음</div>
{items.map((item, idx) => <Item item={item} key={idx} handleClick={handleClick} />)}
</div>
</div>
);
}
export default ItemListContainer;
🟣 설명 (1) : const newCartItems = [...cartItems];
- 이 구문을 사용하지 않으면 cartItems 배열에 직접적인 수정이 일어나기 때문에, 이전의 cartItems 배열 값이 변경되는 부작용이 발생할 수 있다.
- 따라서, 새로운 배열을 생성하고 해당 배열에 변경사항을 반영한 후, setCartItems 함수를 호출하여 상태를 업데이트해야 한다.
- 이렇게 함으로써, 이전 배열의 값이 보존되면서 새로운 값을 업데이트할 수 있다.
- 이를 불변성 유지라고 하며, 리액트에서 상태 업데이트를 안정적으로 수행하기 위해서 중요한 개념 중 하나이다.
- cartItems 배열을 직접 수정하면, 리액트의 상태 관리 메커니즘을 우회하는 것이 되어, 예상치 못한 결과를 초래할 수 있다.
- '불변성 유지'란, 상태를 변경할 때, 이전 상태를 그대로 두고 변경된 새로운 상태를 만드는 것을 의미한다.
- 이전 상태를 그대로 두기 때문에, 상태 변경이 예상치 못한 부작용을 일으키는 경우, 이전 상태로 되돌아갈 수 있다.
- 따라서, const newCartItems = [...cartItems]; 구문을 사용하여 cartItems 배열을 복제하고, 이를 통해 새로운 배열을 만들어 상태를 업데이트하면 된다.
- 이렇게 함으로써, 이전 상태를 그대로 유지하면서도 상태를 안정적으로 업데이트할 수 있다.
- const newCartItems = cartItems;와 같이 배열을 복제하는 대신에 참조를 할당하는 방법으로 상태를 업데이트하면 안 된다.
- 이렇게 하면, 새로운 배열을 만들지 않고 기존 배열의 참조를 사용하기 때문에, 예기치 않은 결과가 발생할 수 있다.
- 예를 들어, const newCartItems = cartItems; 구문을 사용하면 newCartItems와 cartItems 변수가 동일한 배열을 참조하게 된다.
- 이 경우, newCartItems를 수정하면, cartItems도 함께 수정되므로, 이전 상태를 유지하지 않을 수 있다.
2. 장바구니로부터 상품 제거
장바구니 페이지에서 [삭제] 버튼을 누른 후, 해당 상품이 목록에서 삭제되도록 구현한다.
CartItem.js 일부 코드
부모인 장바구니(ShoppingCart.js) 컴포넌트에서 map함수로 하나하나 렌더링된 CartItem 컴포넌트
- "삭제" 버튼에 부모로부터 받아온 handleDelete를 호출시킨다.
export default function CartItem({
item, checkedItems, handleCheckChange, handleQuantityChange, handleDelete, quantity
}) {
return (
.
.
.
.
<button
className="cart-item-delete" onClick={() => { handleDelete(item.id) }}>
삭제
</button>
.
.
.
.
ShoppingCart.js 일부 코드
export default function ShoppingCart({ items, cartItems, setCartItems }) {
.
.
.
const handleDelete = (itemId) => {
setCartItems(cartItems.filter((el)=> el.itemId !== itemId))
setCheckedItems(checkedItems.filter((el) => el !== itemId))
}
.
.
.
.
.
// items에 물건의 모든 정보(img, name 등)가 들어있다.
const renderItems = items.filter((el) => cartItems.map((l) => l.itemId).indexOf(el.id) > -1)
return (
.
.
.
<div id="cart-item-list">
{renderItems.map((item, idx) => {
const quantity = cartItems.filter(el => el.itemId === item.id)[0].quantity
return <CartItem
key={idx} item={item} quantity={quantity} checkedItems={checkedItems}
handleCheckChange={handleCheckChange}
handleQuantityChange={handleQuantityChange}
handleDelete={handleDelete}
/>
})}
</div>
.
.
.
.
3. 장바구니 수량 변경
장바구니 페이지 내에서 장바구니에 담긴 각 아이템 개수를 변경할 수 있도록 구현한다.
CartItem.js 일부 코드
부모인 장바구니(ShoppingCart.js) 컴포넌트에서 map함수로 하나하나 렌더링된 CartItem 컴포넌트
- type이 "number"인 input칸에
- 부모로부터 받아온 handleQuantityChange함수에
- 해당 input에서 받아온 value 값 타입을 Number로 변환한 값과 해당 item의 id를 전달인자로 하여 호출한다.
export default function CartItem({
item, checkedItems, handleCheckChange, handleQuantityChange, handleDelete, quantity
}) {
return (
.
.
.
.
<input
type="number" className="cart-item-quantity"
value={quantity} min={1}
onChange={(e) => { handleQuantityChange(Number(e.target.value), item.id)}}>
</input>
.
.
.
.
ShoppingCart.js 일부 코드
export default function ShoppingCart({ items, cartItems, setCartItems }) {
.
.
.
.
const handleQuantityChange = (quantity, itemId) => {
const newCartItems = [...cartItems]
let index = cartItems.findIndex((item) => item.itemId === itemId)
newCartItems[index].quantity = quantity
setCartItems(newCartItems)
}
//또는 이렇게도 쓸 수 있다.
const handleQuantityChange = (quantity, itemId) => {
setCartItems(cartItems.map(el => {
if(el.itemId === itemId) {
return {'itemId' : itemId, 'quantity' : quantity}
} else return el
}))
}
.
.
.
.
return (
.
.
.
.
<div id="cart-item-list">
{renderItems.map((item, idx) => {
const quantity = cartItems.filter(el => el.itemId === item.id)[0].quantity
return <CartItem
key={idx} item={item} quantity={quantity} checkedItems={checkedItems}
handleCheckChange={handleCheckChange}
handleQuantityChange={handleQuantityChange}
handleDelete={handleDelete}
/>
})}
</div>
.
.
.
.
4. 장바구니에 담긴 특정 아이템 체크, 체크해제 하기
ShoppingCart.js 일부 코드
export default function ShoppingCart({ items, cartItems, setCartItems }) {
// 상태 checkedItems: 체크된 아이템(default 값)은 cartItems에서 itemId만 배열에 넣어져 있다.
// 즉, 카트에 담긴 모든 상품이 체크된 상태를 나타낸다.
const [checkedItems, setCheckedItems] = useState(cartItems.map((el) => el.itemId))
// 특정 아이템을 체크하거나 해제했을 때 (e.target.checked와 해당 상품의 item.id가 전달인자로 들어온다.)
// e.target.checked는 체크가 된 상태면 true, 체크가 안 된 상태면 false
const handleCheckChange = (checked, id) => {
// 체크를 해제했다가 다시 했을 때를 가정하면, 기존의 checkedItems 배열에 해당 아이템을 다시 추가하면 된다.
if (checked) {setCheckedItems([...checkedItems, id]);}
else {setCheckedItems(checkedItems.filter((el) => el !== id));}
};
// '전체 선택'을 체크했을 때 (e.target.checked가 전달인자로 들어온다.)
const handleAllCheck = (checked) => {
if (checked) {setCheckedItems(cartItems.map((el) => el.itemId))}
else {setCheckedItems([]);}
};
.
.
.
.
return (
<div id="item-list-container">
<div id="item-list-body">
<div id="item-list-title">장바구니</div>
<span id="shopping-cart-select-all">
<input
type="checkbox"
checked={checkedItems.length === cartItems.length ? true : false}
onChange={(e) => handleAllCheck(e.target.checked)} >
</input>
<label >전체선택</label>
</span>
CartItem.js 일부 코드
부모인 장바구니(ShoppingCart.js) 컴포넌트에서 map함수로 하나하나 렌더링된 CartItem 컴포넌트
export default function CartItem({
item, checkedItems, handleCheckChange, handleQuantityChange, handleDelete, quantity
}) {
return (
<li className="cart-item-body">
<input
type="checkbox" className="cart-item-checkbox"
onChange={(e) => { handleCheckChange(e.target.checked, item.id) }}
checked={checkedItems.includes(item.id) ? true : false} >
</input>
.
.
.
5. 총합(수량, 가격) 구하기
ShoppingCart.js 일부 코드
export default function ShoppingCart({ items, cartItems, setCartItems }) {
.
.
.
const getTotal = () => {
let cartIdArr = cartItems.map((el) => el.itemId) // 장바구니에 담긴 아이템들의 id정보만 배열에 담는다.
let total = {
price: 0,
quantity: 0,
}
for (let i = 0; i < cartIdArr.length; i++) { // 장바구니에 담긴 아이템들 수 만큼 반복
// 장바구니에 담긴 아이템 중, '체크가 되어 있다면' total로 계산을 해준다.
// 예 : cartIdArr = [1,2,4,5] (1,2,4,5번 상품에 장바구니에 넣어져 있는 상태)
// checkedItems = [1,5] (그 중, 1번과 5번 상품이 체크되어져 있는 상태)
if (checkedItems.indexOf(cartIdArr[i]) > -1) {
let quantity = cartItems[i].quantity
let price = items.filter((el) => el.id === cartItems[i].itemId)[0].price
// [0]을 붙이는 이유는 배열 속 객체의 형태로 존재하기 때문
total.price = total.price + quantity * price
total.quantity = total.quantity + quantity
}
}
return total
}
const total = getTotal() // total 값이 리턴되어 담긴다.
return (
.
.
.
<OrderSummary total={total.price} totalQty={total.quantity} />
.
.
.
OrderSummary.js
import React from 'react'
export default function OrderSummary({ totalQty, total }) {
return (
<div id="order-summary-container">
<h4>주문 합계</h4>
<div id="order-summary">
총 아이템 개수 : <span className="order-summary-text">{totalQty} 개</span>
<hr></hr>
<div id="order-summary-total">
합계 : <span className="order-summary-text">{total} 원</span>
</div>
</div>
</div >
)
}
기타 깨달음
🟣 data-testid
- data-testid는 React 컴포넌트에서 테스트를 위해 사용되는 특수한 속성(attribute)이다.
- 일반적으로, React 테스트 라이브러리들은 테스트를 하기 위해 UI 엘리먼트를 찾는 것이 필요하지만 UI 엘리먼트의 id나 class 속성은 프로덕션 코드에서 스타일링이나 로직에 사용되는 값으로 인해 변경될 가능성이 높기 때문에, 테스트를 위한 식별자로 사용하기에 적합하지 않다.
- 이 속성은 스타일링이나 로직에 영향을 미치지 않고, 테스트에서 사용할 수 있는 고유한 식별자를 제공한다. 이렇게 하면 프로덕션 코드가 변경되어도 테스트가 영향을 받지 않아 안정적인 테스트를 유지할 수 있다.
- 따라서, 아래 코드에서 data-testid는 해당 엘리먼트를 테스트에서 사용할 때 식별하기 위한 고유한 값으로 사용된다.
<div className="cart-item-title" data-testid={item.name}>{item.name}</div>
728x90
'FE > React' 카테고리의 다른 글
Redux로 간단한 Count 기능 구현하기 (0) | 2023.04.24 |
---|---|
상태 관리 라이브러리 Redux (0) | 2023.04.24 |
Props Drilling이란? (0) | 2023.04.21 |
[React] 전역 상태의 필요성 (0) | 2023.04.21 |
Styled Components로 ClickToEdit 만들기 (0) | 2023.04.20 |