드디어 그릭요거트 주문앱에서 담기 기능을 만들어보려고 한다.
ToppingItemForm에서 담을 재료의 수량과 함께 +담기
버튼을 누르면 그 정보를 HeaderCartButton(장바구니 버튼)의 수량 부분에 반영되어야 한다. 그리고 장바구니 버튼을 누르면 담은 재료에 대한 상세 정보와 총계가 모달에 표시되어야 한다.
즉 정리해보면 아래와 같다.
아래처럼 ToppingItemForm에서 재료를 담으면 그 정보를 App으로 올려 Cart와 HeaderCartButton으로 내리는 방법으로 담기 기능을 구현하려고 한다.
완성된 결과는 아래와 같다.
✅ 담은 재료의 수량을 장바구니 버튼에 반영했다.
✅ 모달에서는 담은 재료의 상세 정보를 볼 수 있다.
✅ 모달에서는 담은 재료들의 총 가격도 보여준다.
ToppingItemForm에서 재료를 담으면 HeaderCartButton의 수량 부분에 반영되도록 해보자.
먼저 input에 입력된 수량을 가져오기 위해 ref를 만들어준다. 그 ref의 current.value를 가져오면 수량을 가져올 수 있게 된다. 이것을 숫자형으로 변환하여 prop으로 받은 onSaveAmount에 넘겨주면 상위 컴포넌트인 ToppingItem으로 끌어올릴 수 있다.
// 📃 ToppingItemForm.jsx
import React, { useRef } from 'react';
import Input from '../../UI/Input';
import classes from './ToppingItemForm.module.css';
const ToppingItemForm = () => {
const amountInputRef = useRef();
const submitHandler = (e) => {
e.preventDefault();
const enteredAmount = amountInputRef.current.value;
const enteredAmountNumber = Number(enteredAmount);
props.onSaveAmount(enteredAmountNumber);
}
return (
<form className={classes.form} onSubmit={submitHandler}>
<Input
ref={amountInputRef}
label="수량"
input={{
id: 1,
type: 'number',
min: '1',
max: '10',
step: '1',
defaultValue: '1',
}}
/>
<button type="submit">+ 담기</button>
</form>
);
};
export default ToppingItemForm;
다만 여기서 주의할 점은, 이렇게만 하면 동작하지 않는다는 것이다. 내장된 컴포넌트가 아니라 내가 만든 함수형 컴포넌트이므로 ref 속성을 읽지 못한다.
따라서 Input 내부의 input에 ref를 부여해줘야 한다. 이때 사용하는 특별한 문법이 있는데, forwardRef이다. 이를 이용하면 사용자 지정 컴포넌트(Input.jsx)에서 ref prop을 넘겨 그 내부에 있는 내장된 컴포넌트(<input />
)에 접근할 수 있다. 일반적인 prop은 그냥 넘기면 되지만, ref는 이렇게 넘겨야 한다.
그리고 <input ref={ref} {...props.input} />
은 Input에서 넘긴 input이라는 객체를 스프레드 연산자를 이용하여 모두 가져와 펼친 것이다. 그래서 <input={ref: ref, id: 1, type: 'number', min: '1', max: '10', step: '1', defaultValue: '1'} />
와 같다. 이에 대한 설명은 지난 포스팅에서 확인할 수 있다.
// 📃 Input.jsx
import React, { forwardRef } from 'react';
import classes from './Input.module.css';
const Input = forwardRef((props, ref) => {
return (
<div className={classes.input}>
<label htmlFor={props.input.id} >{props.label}</label>
<input ref={ref} {...props.input} />
</div>
);
});
export default Input;
forwordRef에 대한 설명
공식 문서: https://react.dev/reference/react/forwardRef
관련 블로그: https://www.daleseo.com/react-forward-ref
이제 ToppingItemForm에서 ref로 가져온 수량 데이터를 ToppingItemForm => ToppingItem => AvailableTopping => Toppings => App 순서로 끌어올리고 App => Header => HeaderCartButton으로 내려야 한다.
props drilling이 일어나지만 일단 Context API를 쓰지 않고 만들어보기로 했다.
공통되는 상위 요소인 App 컴포넌트에서 총수량을 의미하는 totalAmount
라는 State를 만들고 0으로 초기화한다.
// 📃 App.jsx
const [totalAmount, setTotalAmount] = useState(0);
ToppingItemForm 컴포넌트에서 수량 데이터를 ref로 가져와 enteredAmount라는 이름의 변수에 넣는다. 그리고 props.onSaveAmount()에 인자로 넣어 ToppingItem 컴포넌트로 끌어올린다.
// 📃 ToppingItemForm.jsx
import React, { useRef } from 'react';
import Input from '../../UI/Input';
import classes from './ToppingItemForm.module.css';
const ToppingItemForm = props => {
const amountInputRef = useRef();
const submitHandler = (e) => {
e.preventDefault();
const enteredAmount = Number(amountInputRef.current.value);
props.onSaveAmount(enteredAmount);
}
return (
<form className={classes.form} onSubmit={submitHandler}>
<Input
ref={amountInputRef}
label="수량"
input={{
id: 1,
type: 'number',
min: '1',
max: '10',
step: '1',
defaultValue: '1',
}}
/>
<button type="submit">+ 담기</button>
</form>
);
};
export default ToppingItemForm;
Input 컴포넌트는 forwardRef를 써서 input ref를 가져온다.
// 📃 Input.jsx
import React, { forwardRef } from 'react';
import classes from './Input.module.css';
const Input = forwardRef((props, ref) => {
return (
<div className={classes.input}>
<label htmlFor={props.input.id} >{props.label}</label>
<input ref={ref} {...props.input} />
</div>
);
});
export default Input;
같은 방식으로 ToppingItem => AvailableTopping => Toppings => App으로 끌어올린다.
App 컴포넌트에서는 가져온 수량 데이터(enteredAmount)을 기존의 총 수량(totalAmount)에 더해서 setTotalAmount로 totalAmount을 갱신해준다. 그러면 수량이 누적된다.
// 📃 App.jsx
function App() {
const [cartIsShown, setCartIsShown] = useState(false);
const [totalAmount, setTotalAmount] = useState(0);
const onSaveAmount = (enteredAmount) => {
setTotalAmount(totalAmount + enteredAmount)
}
정상적으로 수량이 장바구니 버튼에 반영되는 것을 확인할 수 있다!
하지만 나중에는 위에서 만든 ref를 state로 변경하게 되는데...
이제 장바구니 버튼을 누르면 나오는 모달에 재료의 상세 정보를 반영해보자.
먼저 상세 정보를 의미하는 객체를 만들 건데, 여기에는 id, name, price, amount 속성이 들어갈 것이다.
const detail = {
id: ,
name: ,
price: ,
amount: ,
}
재료 상세 정보 객체에 있는 amount는 수량을 의미한다. 그리고 이 정보는 가장 하위에 존재하는 ToppingItemForm에서 ToppingItem 컴포넌트로 올려줘야 한다. 왜냐하면, 이곳에서 수량을 선택하기 때문이다.
그래서 props.onSaveItem(enteredAmount)
가 + 담기
버튼을 누르면 실행되는 submitHandler 안에 있는 것이다. 수량을 선택하고 담은 재료의 수량을 상위 컴포넌트의 onSaveItem 함수에 인자로 넘겨 데이터를 끌어올린다.
// 📃 ToppingItemForm
import React, { useRef } from 'react';
import Input from '../../UI/Input';
import classes from './ToppingItemForm.module.css';
const ToppingItemForm = props => {
const amountInputRef = useRef();
const submitHandler = (e) => {
e.preventDefault();
const enteredAmount = Number(amountInputRef.current.value);
props.onSaveItem(enteredAmount);
}
return (
<form className={classes.form} onSubmit={submitHandler}>
<Input ... />
<button type="submit">+ 담기</button>
</form>
);
};
export default ToppingItemForm;
먼저 selectedItemData라는 객체를 만든다. 그리고 ToppingItemForm에서 끌어올린 수량 정보(amount)를 상세 정보 객체에 합쳐준다. 이것을 상위 컴포넌트로 올릴 것이다.
// 📃 ToppingItem
import React from 'react';
import ToppingItemForm from './ToppingItemForm';
import classes from './ToppingItem.module.css';
const ToppingItem = props => {
const onSaveItem = (amount) => {
const selectedItemData = {
id: props.id,
name: props.name,
price: props.price,
amount: amount
}
props.onSaveItem(selectedItemData);
}
return (
<li className={classes.topping}>
<div className={classes.namePriceDescription}>
<h3 className={classes.name}>{props.name}</h3>
<div className={classes.price}>{`${props.price}원`}</div>
<div className={classes.description}>{props.description}</div>
</div>
<ToppingItemForm onSaveItem={onSaveItem} id={props.id} />
</li>
);
};
export default ToppingItem;
앞서 데이터를 끌어올렸던 방식으로 ToppingItem => AvailableToppings => Toppings => App 컴포넌트로 끌어올렸다.
끌어올린 데이터를 사용할 App 컴포넌트에서 총수량을 의미하는 totalAmount라는 State와 총상세정보를 의미하는 totalItemData라는 State를 만들었다.
// 📃 App.jsx
// import 생략
function App() {
const [cartIsShown, setCartIsShown] = useState(false);
const [totalAmount, setTotalAmount] = useState(0);
const [totalItemData, setTotalItemData] = useState([]);
const onSaveItem = selectedItemData => {
const newTotalItemData = totalItemData.concat(selectedItemData);
setTotalItemData(newTotalItemData);
const newTotalAmount = totalAmount + selectedItemData.amount;
setTotalAmount(newTotalAmount);
}
// 생략
return (
<>
{cartIsShown && <Cart hideCartHandler={hideCartHandler} totalItemData={totalItemData} />}
<Header showCartHandler={showCartHandler} totalAmount={totalAmount} />
<main>
<Toppings onSaveItem={onSaveItem} />
</main>
</>
);
}
export default App;
아래 코드에서 알 수 있듯이 둘 다 기존의 데이터와 새로운 데이터를 합쳐서 누적된 값으로 업데이트되도록 했다.
const newTotalItemData = totalItemData.concat(selectedItemData);
setTotalItemData(newTotalItemData);
const newTotalAmount = totalAmount + selectedItemData.amount;
setTotalAmount(newTotalAmount);
}
이제 totalItemData를 Cart에 prop으로 전달하여 장바구니 버튼을 누르면 담은 재료의 상세 정보를 볼 수 있게 했고, totalAmount를 Header에 전달하여 총 수량이 장바구니 버튼 위에 표시될 수 있도록 했다.
// 불필요한 코드 생략
<Cart totalItemData={totalItemData} />
<Header totalAmount={totalAmount} />
정상적으로 상세 정보가 반영되었다😊
카테고리가 바뀌어도 해당 카테고리에서 입력한 수량이 유지되도록 재료의 상세 정보를 의미하는 객체 State를 만들었다. 수량(amount)이 바뀔 때마다 onChangeHandler 함수가 실행되며 setItemState로 itemState를 업데이트할 것이다.
하지만 여기까지만 하면 카테고리를 바꿨을 때 수량이 초기화될뿐 이전에 입력한 수량이 남아있지는 않는다. 따라서 상위 컴포넌트에 각 카테고리별로 State를 저장해주기로 했다.
// ToppingItemForm.jsx
import React, { useState, useEffect } from 'react';
import Input from '../../UI/Input';
import classes from './ToppingItemForm.module.css';
const ToppingItemForm = props => {
const [itemState, setItemState] = useState({
id: props.topping.id,
name: props.topping.name,
description: props.topping.description,
price: props.topping.price,
amount: props.topping.amount
});
const submitHandler = (e) => {
e.preventDefault();
const enteredAmount = Number(item.amount);
item.amount = enteredAmount;
}
const onChangeHandler = (e) => {
setItemState(prev => {
return {
...prev,
amount: e.target.value,
}
})
}
return (
<form className={classes.form} onSubmit={submitHandler} key={props.id} >
<Input
onChange={onChangeHandler}
label="수량"
input={{
id: props.id,
type: 'number',
min: '1',
max: '10',
step: '1',
value: itemState.amount,
}}
/>
<button type="submit">+ 담기</button>
</form>
);
};
export default ToppingItemForm;
이렇게 카테고리를 구현하는데 많은 시행착오를 겪어서 그것은 다른 포스팅으로 분리해서 작성해보려고 한다.
수량을 변경해서 담으면 담은 재료의 수량만큼 장바구니 버튼에 총 수량이 잘 반영된다! 그리고 모달을 열면 담은 재료의 상세 정보가 표시되며 재료들의 총계도 아랫쪽에 표시된다.
장바구니에서 같은 재료면 수량 누적해서 보여주기
장바구니 안에서 +, - 기능 구현하기
장바구니 안에서 삭제 기능 구현하기
props drilling 피하기(재정의 필요 없는 것부터 지우기)
destructuring 활용하기: React에서 props는 언제나 object여서 1단계 destructuring은 언제나 성공하기 때문에 쓰지 않을 이유가 없다
// 지금까지 이렇게 써옴
const Component = (props) => ... props.topping ...
// 앞으로는 이렇게 쓰자
const Component = ({ topping }) => ... topping ...
context API 사용하여 한군데에서 state 관리하기
적절한 곳에 reducer 사용하기
담기 기능을 일부 구현해보았다. 포스팅이 너무 길어질 것 같아서 여기서 끊어 가기로 했다. 사실 담기 기능을 만들면서 카테고리 부분에서 심각한 오류를 발견했고 그 문제를 해결하는데 긴 시간을 썼다. 이때 겪은 시행착오들을 다음 포스팅에서 다루려고 한다.
그리고 이번 포스팅에서 적은 내용은 이후에 카테고리 오류를 해결하는 과정에서 대부분 수정되었다..😂