최근 SWR과 더불어 주목받고 있는 Recoil을 이용하여 커비샵의 장바구니 상태를 관리하게 되었다.
먼저 store/cart.ts
경로를 생성하고 다음과 같이 Cart에 대한 상태를 만든다.
import { atom } from 'recoil';
import { TCart } from 'src/graphql/cart';
export const checkedCartState = atom<TCart[]>({
key: 'cartState',
default :[],
})
TCart
타입의 배열로 cart의 state를 관리할 수 있도록 만든 것이다. CartList
페이지에 담아져있는 Cart의 정보를 가져올 수 있도록 하자.
완성된 화면이다.
체크박스를 조작하기 위해서는 체크박스 DOM을 가져올 수 있도록 해당 DOM을 선택해야 한다. DOM을 조작하는 데 있어 최적의 훅은 useRef
이므로 이를 이용해보자.
const formRef = useRef<HTMLFormElement>(null);
useRef
를 이용하여 formElement를 지정할 수 있는 Ref 객체를 만들었다. 해당 객체를 선택하고 싶은 DOM에 ref값을 지정해주면 된다. 지정을 하고 나면 Ref객체의 .current
값은 원하는 DOM을 가리키게 될 것이다.
formRef로 지정하고자 하는 것은 form 타입 HTML이기 때문에 HTMLFormElement
로 지정한다. 현재 DOM을 직접 조작하기 위해 프로퍼티로 useRef 객체를 사용하기 때문에, RefObject를 사용해야 하므로 초깃값으로 null을 넣어주어야 한다.
const checkboxRefs = items.map(()=> useRef<HTMLInputElement>(null));
전체적인 form에 대하여 관리하는 Ref를 만들어 주었으니, Item들의 checkbox를 감시할 ref 또한 만들어 주는 것이 인지상정!!! 이후 모든 item들에 대하여 순회하면서 각 체크박스에 대한 Ref를 만든다. 그리고 만들어 둔 ref를 각 Item에 배정해준다.
{items.map((item, i) => <CartItem { ...item} key = {item.id} ref = {checkboxRefs[i]}/>)}
전달된 ref
는 각 아이템의 input
태그에 적용하여 check input 이벤트 DOM을 콕 찝어 올 것이다!
FormData
는 폼을 쉽게 조작할 수 있도록 하는 객체이다. 더욱 자세한 설명은 링크를 확인하자.
let formData = new FormData([form]);
HTML에 form 요소가 있는 경우, 위와 같은 코드를 작성하면 해당 폼 요소의 필드 전체가 객체에 자동 반영된다!
이제 사용할 요소 하나하나 보았으니 현재 컴포넌트가 어떤 값을 랜더링하는지 확인해보자.
return (
<form ref = {formRef} onChange = {handleCheckboxChanged}>
<CartContainer>
<CartHeader>
<Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
<input className= "select-all" name = "select-all" type = "checkbox" />
전체선택
</Text>
<Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
상품정보
</Text>
<Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
상품금액
</Text>
</CartHeader>
{items.map((item, i) => <CartItem { ...item} key = {item.id} ref = {checkboxRefs[i]}/>)}
<PayAmount handleSubmit = {handleSubmit} submitTitle="구매하기"/>
</CartContainer>
</form>
);
전체 form 태그에 formRef
를 지정하였다. 앞으로 formRef
는 form태그 전체를 DOM요소로 인식하게 될 것이며 current
의 타입은 HTMLFormElement
일 것이다.
form이 바뀔 때마다 handleCheckboxChanged
함수가 작동한다.
const handleCheckboxChanged = (e: SyntheticEvent) => {
if(!formRef.current) return;
const targetInput = e?.target as HTMLInputElement;
if (targetInput && targetInput.classList.contains('select-all')) {
setItemsCheckedFromAll(targetInput)
} else {
setAllCheckedFromItems()
}
const data = new FormData(formRef.current);
setFormData(data);
}
현재 가리키고 있는 current
요소가 있는지 없는지 검증을 해준 뒤에 아래 함수를 실행한다. 현재 이벤트를 일으킨 것은 체크박스일 것이다. (HTMLInputElement
)
아래 콘솔창의 모습은 targetInput
의 모습이다. 현재는 아이템 개별 요소를 선택하였기 때문에 select-item
의 클래스를 가진다.
이제 이 개념을 바탕으로 전체 선택과 개별 선택을 구현해보자.
<CartHeader>
<Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
<input className= "select-all" name = "select-all" type = "checkbox" />
전체선택
</Text>
<Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
상품정보
</Text>
<Text typo ='Text_16_SB' className = "info" color ='black' as ='h3'>
상품금액
</Text>
</CartHeader>
전체 선택 버튼은 select-all
이라는 클래스를 가졌다.
const handleCheckboxChanged = (e: SyntheticEvent) => {
if(!formRef.current) return;
const targetInput = e?.target as HTMLInputElement;
if (targetInput && targetInput.classList.contains('select-all')) {
setItemsCheckedFromAll(targetInput)
} else {
setAllCheckedFromItems()
}
const data = new FormData(formRef.current);
setFormData(data);
}
만약 select-all
을 포함한 input이라면 setItemsCheckedFromall
을 수행하도록 한다. 그럼 해당 함수가 어떤 역할을 하는지 살펴보자.
const setItemsCheckedFromAll = (targetInput: HTMLInputElement) => {
const allChecked = targetInput.checked
checkboxRefs.forEach(inputElem => {
inputElem.current!.checked = allChecked;
})
}
전체 선택 버튼이 체크되어있는지 여부를 반환하는 allChecked
라는 변수를 만든다. 이후, checkboxRef
를 하나씩 순회하며 input
태그의 current
check상태를 true로 만든다. 만약 전체 선택 버튼이 false라면 current check상태는 모두 false로 바뀔 것이다.
const handleCheckboxChanged = (e: SyntheticEvent) => {
if(!formRef.current) return;
const targetInput = e?.target as HTMLInputElement;
if (targetInput && targetInput.classList.contains('select-all')) {
setItemsCheckedFromAll(targetInput)
} else {
setAllCheckedFromItems()
}
const data = new FormData(formRef.current);
setFormData(data);
}
setAllCheckedFromItems()
함수를 실행한다. 해당 함수가 어떻게 생겼는지 보자!
const setAllCheckedFromItems = () => {
if(!formRef.current) return;
const data = new FormData(formRef.current);
const selectedCount = data.getAll('select-item').length;
const allChecked = selectedCount === items.length;
formRef.current.querySelector<HTMLInputElement>('.select-all')!.checked = allChecked;
}
selectedCount
는 현재 선택된 select-item
클래스를 가진 것을 세 준다. 만약, 선택된 것의 개수가 아이템의 개수와 같다면 모두 선택된 것이므로 allChecked
를 true로 설정해주고, 전체 선택 버튼을 가지고 와서 해당 값을 세팅해준다.
마지막으로 현재 form 태그의 상태를 기억할 수 있도록 아래 코드를 수행한다.
const data = new FormData(formRef.current);
setFormData(data);
이제 장바구니 체크 관련한 기능은 다 만들었다. 이러한 체크된 상품만이 결제 페이지로 넘어가야 한다. 이 부분은 recoil을 이용하여 상태 관리를 하도록 하자.
useEffect(()=> {
const checkedItems = checkboxRefs.reduce<TCart[]>((res, ref, i) => {
if (ref.current!.checked) res.push(items[i]);
return res;
}, [])
setCheckedCartData(checkedItems)
}, [items, formData]);
items
나 formData
의 값이 바뀔때마다 useEffect
함수를 실행한다. 체크된 아이템의 상태를 알기 위하여 checkboxRefs
의 상태를 reduce
함수를 이용하여 연산을 실행한다. 현재 checkboxRefs
에는 모든 input들에 대한 ref가 저장되어 있는 상태이다. 만약 현재 아이템이 체크된 상태라면 해당 아이템을 res
배열에 넣어서 반환한다. 체크된 아이템들만 recoil로 의하여 전역적으로 관리된다.
처음에 장바구니를 랜더링 할 때에는 이전에 내가 체크해둔 상태를 저장해야 할 필요가 있다.
이럴 때를 대비하여 첫 랜더링을 대하는 useEffect 함수도 만들어주자.
useEffect(() => {
checkedCartData.forEach(item => {
const itemRef = checkboxRefs.find(ref => ref.current!.dataset.id === item.id);
if(itemRef) itemRef.current!.checked = true
})
setAllCheckedFromItems();
}, [])
recoil로 관리되고 있는 체크된 값들만 들어 있는 state를 하나씩 돌면서 현재 아이템으로 랜더링 될 것들의 ref들을 하나씩 가져온다. ref의 data가 가지고 있는 Id와 일치하는 것만을 찾아 체크된 상태를 true로 설정하며, 전체 선택을 자동으로 실행할 수 있도록 setAllCheckedFromItems
함수를 하단에 추가해준다.
그럼 이로써 장바구니를 통하여 체크된 아이템들만 recoil로 관리할 수 있게 된 것이다.
현재 장바구니에 체크된 아이템만 결제를 진행해야 하므로 결제 페이지에 recoil로 만들어 둔 checkedCartData
를 이용할 필요성이 존재한다.
const checkedItems = useRecoilValue(checkedCartState);
const [checkedCartData, setCheckedCartData] = useRecoilState(checkedCartState);
return (
<div>
<PayContainer>
<Text typo='G_Header_24_B'>구매자 정보</Text>
<PayInfo/>
<Text typo='G_Header_24_B'>배송 1건 중</Text>
<DeilverInfoContainer>
<div className = 'InfoHead'>
<Text typo = 'Header_20'>내일 도착 예정</Text>
</div>
{checkedItems.map(({ title, id, amount }) => (
<Paydeilver key = {id} title={title} amount = {amount}/>
))}
</DeilverInfoContainer>
</PayContainer>
<PayAmount handleSubmit = {showModal} submitTitle ="결제하기"/>
<PaymentModal show = {modalShown} proceed = {proceed} cancel = {cancel}/>
</div>
);
이렇게 전역 데이터를 받아와 화면에 뿌린다. 이후 결제를 진행해야 하고, 결제가 완료된다면 관리하고 있는 전역 데이터를 초기화해야 한다.
결제해야 하는 금액을 구하는 코드는 아래와 같다.
const checkedItems = useRecoilValue(checkedCartState);
const totalPrice = checkedItems.reduce((res, {price, amount}) => {
res += price * amount;
return res;
}, 0);
reduce
함수를 통하여 간단히 구할 수 있다. price, amount
의 값을 받아서 그 개수만큼 곱하고 결과론적으로 res
를 반환하도록 한다.
결제가 완료되면 아래 함수를 수행한다.
const proceed = () => {
const ids = checkedCartData.map(({ id }) => id)
executePay(ids, {
onSuccess: () => {
setCheckedCartData([])
alert('결제가 완료되었습니다.😊')
navigate('/products', { replace: true })
},
})
전역적으로 관리하고 있는 recoil 값을 초기화해주고, graphql로 설정한 EXECUTE_PAY
함수를 실행하였기 때문에 결제한 물품은 장바구니에서 사라지도록 한다.