[ React - 쇼핑몰 사이트 ] mutation (update & delete) & checkbox handling (forwarded Ref)
장바구니 수량 업데이트
장바구니 수량 업데이트 쿼리문 작성
기존 ADD_CART 쿼리문과 비슷하나 amount가 추가된 것이다.
▼ src/graphql/cart.js
export const UPDATE_CART = gql`
mutation UPDATE_CART($id: string, $amount: number) {
cart(id: $id, amount: $amount) {
id
imageUrl
price
title
amount
}
}
`;
업데이트 핸들러 함수 정의
업데이트를 하는데 cartData가 없는 것은 에러를 발생시킨다.
기존에서 amount만 전달된 값으로 바꿔주면 된다.
▼ src/mocks/handler.ts
graphql.mutation(UPDATE_CART, (req, res, ctx) => {
const newCartData = { ...cartData };
const { id, amount } = req.variables;
if (!newCartData[id]) {
throw new Error("없는 데이터입니다.");
}
const newItem = {
...newCartData[id],
amount,
};
newCartData[id] = newItem;
cartData = newCartData;
return res(ctx.data(newItem));
}),
=> 이렇게만 했을 때의 문제점은 amount의 수량을 화살표키(?)로 증감하여도 데이터 상으로는 amount가 변경되지만, 화면으로 보여지는 것은 바로 변경되지 않는다.
Query Invalidation
[Query Invalidation | TanStack Query Docs
Waiting for queries to become stale before they are fetched again doesn't always work, especially when you know for a fact that a query's data is out of date because of something the user has done. For that purpose, the QueryClient has an invalidateQueries
tanstack.com](https://tanstack.com/query/v4/docs/react/guides/query-invalidation)
invalidateQueries()
: 캐시된 쿼리들을 무효화(invalidate)한다. 특정 쿼리나 쿼리 그룹에 속한 모든 쿼리들을 강제로 재요청하고, 캐시된 데이터를 업데이트할 수 있다.
▼ src/components/cart/item.tsx
const handleUpdateAmount = (e: SyntheticEvent) => {
const amount = Number((e.target as HTMLInputElement).value);
updateCart(
{ id, amount },
{
onSuccess: () => queryClient.invalidateQueries(QueryKeys.CART),
}
);
};
onSuccess에 넣지 않고 따로 넣을 경우 updateCart와 invalidationQueries 둘 다 비동기 요청이기 때문에 순서가 뒤죽박죽되어 제대로 작동하지 않는 것을 주의하자.
좋은 방법인가?
: 하나의 변경에도 모든 API를 다시 요청하므로 효율적인 방법이라고 볼 수 없다. request는 줄일 수 있다면 줄여야 한다. 캐시를 변경하는 방법은 없을까?
위 사진에서 보이는 것처럼 UPDATE를 할 때마다 GET을 같이한다.
Optimistic Updates(낙관적 업데이트)
[Optimistic Updates | TanStack Query Docs
When you optimistically update your state before performing a mutation, there is a chance that the mutation will fail. In most of these failure cases, you can just trigger a refetch for your optimistic queries to revert them to their true server state. In
tanstack.com](https://tanstack.com/query/v4/docs/react/guides/optimistic-updates)
- Optimistic Updates는 사용자 인터페이스에서 발생한 작업을 즉시 반영하여 응답을 기다리지 않고 사용자 경험을 개선하는 기술이다. 일반적으로 네트워크 요청에 의해 데이터가 변경될 때 사용되며, 사용자의 작업이 서버에 도달하기 전에 로컬 상태를 변경하여 향상된 반응성을 제공한다.
- 만약 동일 useQuery를 쓰는 뷰가 많다면, 1개 업데이트로 전부 반영이 되므로 처음에 정의하기에 번거로울지라도 더 효율적일 수 있다.
▼ src/components/cart/item.tsx
const { mutate: updateCart } = useMutation(
({ id, amount }: { id: string; amount: number }) =>
graphqlFetcher(UPDATE_CART, { id, amount }),
{
onMutate: async ({ id, amount }) => {
await queryClient.cancelQueries(QueryKeys.CART);
const prevCart = queryClient.getQueryData<{ [key: string]: Cart }>(
QueryKeys.CART
);
if (!prevCart?.[id]) return prevCart;
const newCart = {
...(prevCart || {}),
[id]: { ...prevCart[id], amount },
};
queryClient.setQueryData(QueryKeys.CART, newCart);
return newCart;
},
onSuccess: (newValue) => {
const prevCart = queryClient.getQueryData<{
[key: string]: Cart;
}>(QueryKeys.CART);
const newCart = {
...(prevCart || {}),
[id]: newValue,
};
queryClient.setQueryData(QueryKeys.CART, newCart);
},
}
);
onSucess에 들어오는 newVaule는 바뀐 값 하나만 들어오는데, Cart 전체에 대한 데이터를 변경해야하므로, 나머지는 그대로 두고 새로 들어온 데이터만 변경된다.
update할 때마다 get cart를 하지 않게 된다.
상품 수량 업데이트 mutation 호출&사용
amount가 1보다 작으면 그냥 return하도록 한다.
▼ src/components/cart/item.tsx
const handleUpdateAmount = (e: SyntheticEvent) => {
const amount = Number((e.target as HTMLInputElement).value);
if (amount < 1) return;
updateCart({ id, amount });
};
수량(amount)을 1 아래로 조절할 수 없도록 최솟값을 설정하고 handleUpdateAmount함수를 onChange함수에 연결해주었다.
<label>
<input className="cart-item_amount" type="number" value={amount} min={1} onChange={handleUpdateAmount />{" "} 개
</label>
장바구니 페이지 스타일링
각 상품마다 삭제버튼과 체크박스, 전체에 대한 전체선택 체크박스를 추가하였다.
.cart-item {
display: flex;
flex-direction: row;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid #000;
justify-content: space-between;
align-items: center;
&_image {
width: 100%;
height: 200px;
object-fit: contain;
}
}
장바구니 항목 삭제 기능
삭제 쿼리문 작성
▼ src/graphql/cart.ts
export const DELETE_CART = gql`
mutation DELETE_CART($id: string) {
id
}
`;
삭제 핸들러 등록
▼ src/mockes/handler.ts
graphql.mutation(DELETE_CART, (req, res, ctx) => {
const id = req.variables.id;
const newCartData = { ...cartData };
delete newCartData[id];
cartData = newCartData;
return res(ctx.data(id));
}),
삭제 mutation 호출&사용
- 낙관적 업데이트 대신 invalidateQueries를 사용했다.
▼ src/components/cart/item.tsx
const { mutate: deleteCart } = useMutation(
({ id }: { id: string }) => graphqlFetcher(DELETE_CART, { id }),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.CART);
},
}
);
const handleDeleteItem = () => {
deleteCart({ id });
};
<button className="cart-item_removeButton" type="button" onClick={handleDeleteItem}>x</button>
장바구니 상품 전체선택 처리하기
제어 컴포넌트 방식(state 사용)을 사용하지 않고 비제어 컴포넌트 방식을 사용한다.
formdata 사용하기
- Formdata는 HTML단이 아닌 자바스크립트 단에서 폼 데이터를 다루는 JAVASCRIPT API이다.
- FormData 객체는 자동으로 name 속성이 있는 요소들에서만 데이터를 수집하고, name 속성이 없는 요소는 무시한다.
createRef 사용하기
- CartItem 컴포넌트에 ref를 넘겨주기 위해서 items.map에 createRef로 ref를 만들어 checkboxRefs에 저장한다.
- CartItem 컴포넌트를 호출할 때에 ref값도 함께 넘겨준다.
▼ src/components/cart/index.tsx
...
const checkboxRefs = items.map(() => createRef<HTMLInputElement>());
...
{items.map((item, i) => (
<CartItem {...item} key={item.id} ref={checkboxRefs[i]} />
))}
...
forwardedRef 사용하기
- 장바구니에 있는 상품목록의 ref를 정의하고 부모 컴포넌트에서 ref에 접근해야 하므로 forwaredRef를 사용한다.
- CartItem의 props에 ref를 추가한다.
(ForwardedRef 식으로 ) - input태그의 ref를 props의 ref로 지정해준다.
- export를 해줄때 forwaedRef로 감싸준다.
▼ src/components/cart/item.tsx
...
const CartItem = (
{ id, title, imageUrl, price, amount }: Cart,
ref: ForwardedRef<HTMLInputElement>
) => {
...
<input
className="cart-item_checkbox"
type="checkbox"
name={`select-item`}
ref={ref}
/>
...
export default forwardRef(CartItem);
...
전체선택 핸들러 함수
- 전체선택 체크박스가 선택되면 모든 체크박스가 선택된다.
- 전체선택 체크박스가 선택해제되면 모든 체크박스가 선택해제된다.
- 전체선택 체크박스가 선택되지 않은 상태일 때 선택된 체크박스의 갯수가 전체 아이템의 갯수와 같으면(모두 선택된 경우) 전체선택 체크박스가 선택된다.
▼ src/components/cart/index.tsx
const formRef = useRef<HTMLFormElement>(null);
const handleCheckboxChanged = (e: SyntheticEvent) => {
if (!formRef.current) return;
const targetInput = e.target as HTMLInputElement;
const data = new FormData(formRef.current);
const selectedCount = data.getAll("select-item").length;
if (targetInput.classList.contains("cart_select-all")) {
const allchecked = targetInput.checked;
checkboxRefs.forEach((inputElem) => {
inputElem.current!.checked = allchecked;
});
} else {
const allchecked = selectedCount === items.length;
formRef.current.querySelector<HTMLInputElement>(
".cart_select-all"
)!.checked = allchecked;
}
};
전체를 form으로 감싸고 ref를 formRef로 지정하고 onChange함수에handleCheckboxChanded함수를 넣어준다.
return (
<form ref={formRef} onChange={handleCheckboxChanged}>
<label>
<input className="cart_select-all" name="select-all" type="checkbox" />
전체선택
</label>
<ul className="cart">
{items.map((item, i) => (
<CartItem {...item} key={item.id} ref={checkboxRefs[i]} />
))}
</ul>
</form>
);
첫번째 사진처럼 전체선택되지 않았을 때 두번째 사진처럼 선택되지 않은 나머지 두 항목을 체크하면 전체선택 체크박스도 함께 체크된다.