REACT/쇼핑몰 프로젝트

[REACT] React + Typescript + Vite + SCSS로 만드는 쇼핑몰 개인프로젝트 (3/10)

예글 2024. 1. 18. 15:41

1️⃣ 장바구니 페이지

1. pages/cart/index.tsx

import { useQuery } from "react-query";
import { QueryKeys, graphqlFetcher } from "../../queryClient";
import GET_CART, { Cart } from "../../graphql/cart";
import CartList from "../../assets/components/cart/list";

const CartIndex = () => {
  // 장바구니에 담은 아이템들 불러오기
  const { data } = useQuery(QueryKeys.CART, () => graphqlFetcher(GET_CART), {
    // 처음에 장바구니에 담고 다른 페이지 갔다가 돌아와서 다시 누르면 안 들어가는 현상 때문에 staleTime과 cacheTime을 따로 설정
    staleTime: 0,
    cacheTime: 1000,
  });

  const cartItems = Object.values(data || {}) as Cart[];

  if (!data) return <div>장바구니가 비었어요</div>;

  return <CartList items={cartItems} />;
};

export default CartIndex;

 

2. components/cart/list.tsx

import { Cart } from "../../../graphql/cart";
import CartItem from "./item";

const CartList = ({ items }: { items: Cart[] }) => {
  return (
    <ul>
      {items?.map((item: any) => (
        <CartItem {...item} key={item.id} />
      ))}
    </ul>
  );
};

export default CartList;

 

3. components/cart/item.tsx

import { useMutation } from "react-query";
import { Cart, UPDATE_CART } from "../../../graphql/cart";
import { graphqlFetcher } from "../../../queryClient";
import { SyntheticEvent } from "react";

const CartItem = ({ id, imageUrl, price, title, amount }: Cart) => {

  // 장바구니에서도 수량 변경을 할 수 있게 하기 위해 추가
  const { mutate: updateCart } = useMutation(
    ({ id, amount }: { id: string; amount: number }) =>
      graphqlFetcher(UPDATE_CART, { id })
  );

  // 수량 업데이트 함수
  const handleUpdateAmount = (e: SyntheticEvent) => {
    const amount = Number((e.target as HTMLInputElement).value);
    updateCart({ id, amount });
  };

  return (
    <li className="cart-item">
      <img src={imageUrl} alt="" />
      <p className="cart-item__price">{price}</p>
      <p className="cart-item__title">{title}</p>
      <input
        type="number"
        className="cart-item__amount"
        value={amount}
        onChange={handleUpdateAmount}
      />
    </li>
  );
};

export default CartItem;

 

4. mocks/handlers.ts 에 장바구니 업데이트 부분 추가

// 장바구니 업데이트
  graphql.mutation(UPDATE_CART, (req, res, ctx) => {
    const newData = { ...cartData };
    const { id, amount } = req.variables;

    if (!newData[id]) {
      throw new Error("없는 데이터입니다");
    }

    const newItem = {
      ...newData[id],
      amount,
    };

    newData[id] = newItem;
    cartData = newData;

    return res(ctx.data(newItem));
  }),

 

5. graphql/cart.ts 에도 장바구니 업데이트 부분 추가

// 장바구니 업데이트
export const UPDATE_CART = gql`
  mutation UPDATE_CART($id: string, $amount: number) {
    cart(id: $id, amount: $amount) {
      id
      imageUrl
      price
      title
      amount
    }
  }
`;

 

2️⃣ invalidateQueries vs 낙관적 업데이트

처음에 장바구니 아이템 수량을 업데이트를 하려고 할 때 invalidateQueries를 사용하려고 했었다.

하지만 여러번 요청을 보내야 하므로 낙관적 업데이트를 통해 수량을 업데이트 하는 게 나을 것 같다고 하셨다.

 

1. invalidateQueries

:  캐시된 쿼리들을 무효화(invalidate)한다. 특정 쿼리나 쿼리 그룹에 속한 모든 쿼리들을 강제로 재요청하고, 캐시된 데이터를 업데이트할 수 있다.

const handleUpdateAmount = (e: SyntheticEvent) => {
    const amount = Number((e.target as HTMLInputElement).value);
    updateCart(
      { id, amount },

      mutation -> query 이 순서로 뜨게 하기 위해
      {
        onSuccess: () => queryClient.invalidateQueries(QueryKeys.CART),
      }
    );
  };

 

- updateCart와 invalidataionQueries 둘 다 비동기 요청,, 비동기 요청으로 인해 순서가 바뀔 수 있으므로 onSuccess에 넣어준다.

 

2. 낙관적 업데이트

  • Optimistic Updates는 사용자 인터페이스에서 발생한 작업을 즉시 반영하여 응답을 기다리지 않고 사용자 경험을 개선하는 기술이다. 일반적으로 네트워크 요청에 의해 데이터가 변경될 때 사용되며, 사용자의 작업이 서버에 도달하기 전에 로컬 상태를 변경하여 향상된 반응성을 제공한다.
  • 만약 동일 useQuery를 쓰는 뷰가 많다면, 1개 업데이트로 전부 반영이 되므로 처음에 정의하기에 번거로울지라도 더 효율적일 수 있다.
const { mutate: updateCart } = useMutation(
    ({ id, amount }: { id: string; amount: number }) =>
      graphqlFetcher(UPDATE_CART, { id, amount }),
    {
      onMutate: async ({ id, amount }) => {
        await queryClient.cancelQueries(QueryKeys.CART);

        // Snapshot the previous value
        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 prevCart;
      },
      onSuccess: (newValue) => {
        // 아이템 하나에 대한 데이터
        const prevCart = queryClient.getQueryData<{ [key: string]: Cart }>(
          QueryKeys.CART
        );
        const newCart = {
          ...(prevCart || {}),
          [id]: newValue,
        };
        queryClient.setQueryData(QueryKeys.CART, newCart); // Cart 전체에 대한 데이터
      },
    }
  );

 

 

3️⃣ 장바구니 페이지 선택,  전체선택 버튼 추가 / 삭제

- 제어 컴포넌트 방식(state 사용)을 사용하지 않고 비제어 컴포넌트 방식 사용

 

💡 formdata 사용하기

  • Formdata는 HTML단이 아닌 자바스크립트 단에서 폼 데이터를 다루는 JAVASCRIPT API이다.
  • FormData 객체는 자동으로 name 속성이 있는 요소들에서만 데이터를 수집하고, name 속성이 없는 요소는 무시한다.

💡 createRef 사용하기

  • CartItem 컴포넌트에 ref를 넘겨주기 위해서 items.map에 createRef로 ref를 만들어 checkboxRefs에 저장한다.
  • CartItem 컴포넌트를 호출할 때에 ref값도 함께 넘겨준다.

 

1. components/cart/list.tsx

import { SyntheticEvent, createRef, useRef } from "react";
import { Cart } from "../../../graphql/cart";
import CartItem from "./item";

const CartList = ({ items }: { items: Cart[] }) => {
  // 전체 폼 요소에 대한 참조
  const formRef = useRef<HTMLFormElement>(null);

  // 각 아이템 체크박스 요소에 대한 참조 배열
  const checkboxRefs = items.map(() => createRef<HTMLInputElement>());

  // 전책 선택 핸들러 함수
  const handleCheckboxChanged = (e: SyntheticEvent) => {
    if (!formRef.current) return;

    // 변경된 체크박스 요소 확인
    const targetInput = e.target as HTMLInputElement;

    // 현재 폼의 데이터를 FormData로 가져오기
    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;
    }
  };

  return (
    // 폼 엘리먼트에 참조를 설정하고, 체크박스 변경 시 이벤트 핸들러를 호출
    <form ref={formRef} onChange={handleCheckboxChanged}>
      <label>
        <input className="cart_select-all" type="checkbox" name="select-all" />
        전체선택
      </label>

      <ul className="cart">
        {items?.map((item: any, i) => (
          <CartItem {...item} key={item.id} ref={checkboxRefs[i]} />
        ))}
      </ul>
    </form>
  );
};

export default CartList;

 

 

2. components/cart/item.tsx

import { useMutation } from "react-query";
import { Cart, DELETE_CART, UPDATE_CART } from "../../../graphql/cart";
import { QueryKeys, getClient, graphqlFetcher } from "../../../queryClient";
import { forwardRef, ForwardedRef, SyntheticEvent } from "react";

const CartItem = (
  { id, imageUrl, price, title, amount }: Cart,
  ref: ForwardedRef<HTMLInputElement>
) => {
  const queryClient = getClient();

  // 장바구니 수량 업데이트
  const { mutate: updateCart } = useMutation(
    ({ id, amount }: { id: string; amount: number }) =>
      graphqlFetcher(UPDATE_CART, { id, amount }),
    {
      onMutate: async ({ id, amount }) => {
        await queryClient.cancelQueries(QueryKeys.CART);

        // Snapshot the previous value
        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 prevCart;
      },
      onSuccess: (newValue) => {
        // 아이템 하나에 대한 데이터
        const prevCart = queryClient.getQueryData<{ [key: string]: Cart }>(
          QueryKeys.CART
        );
        const newCart = {
          ...(prevCart || {}),
          [id]: newValue,
        };
        queryClient.setQueryData(QueryKeys.CART, newCart); // Cart 전체에 대한 데이터
      },
    }
  );

  // 장바구니 삭제
  const { mutate: deleteCart } = useMutation(
    ({ id }: { id: string }) => graphqlFetcher(DELETE_CART, { id }),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(QueryKeys.CART);
      },
    }
  );

 // 수량 업데이트 함수
  const handleUpdateAmount = (e: SyntheticEvent) => {
    const amount = Number((e.target as HTMLInputElement).value);
    if (amount < 1) return;
    updateCart({ id, amount });
  };

 // 삭제 함수
  const handleDeleteItem = () => {
    deleteCart({ id });
  };

  return (
    <li className="cart-item">
      <input
        className="cart-item__checkbox"
        type="checkbox"
        name={`select-item`}
        ref={ref}
      />
      <img className="cart-item__image" src={imageUrl} alt="" />
      <p className="cart-item__price">{price}</p>
      <p className="cart-item__title">{title}</p>
      <input
        type="number"
        className="cart-item__amount"
        value={amount}
        onChange={handleUpdateAmount}
        min={1}
      />

      <button
        className="cart-item__button"
        type="button"
        onClick={handleDeleteItem}
      >
        삭제
      </button>
    </li>
  );
};

export default forwardRef(CartItem);

 

  • forwrdRef - CartItem 컴포넌트를 외부에서 참조 가능하도록 만들어줌. 부모 컴포넌트에서 'ref'를 통해 자식 컴포넌트에 직접 접근할 때 유용
  • ref : ForwardedRef<HTMLInputElement>  - CartItem 컴포넌트에 전달되는 'ref'에 대한 타입 지정. 부모 컴포넌트에서 CartItem에 접근할 때 해당 ref 사용 가능

3. mocks/handler.ts

// 장바구니 지우기
  graphql.mutation(DELETE_CART, ({ variables: { id } }, res, ctx) => {
    const newData = { ...cartData };
    delete newData[id];
    cartData = newData;
    return res(ctx.data(id));
  }),

 

 

3일차 끝..!

state를 사용하지 않고 바닐라 js로 해보는 건 처음이었는데.. 역시 근본인 js를 잘해야하는구나를 깨달았다..

어려웠지만 하나하나 해석해보고 새로운 걸 습득한 거로만으로도 만족! 

 

열심히 해야겠다 정말..!!