flex로 대부분 다 해결이되어서 잘 사용하지 않아 해도해도 적응 안되는 grid.. 최종 프로젝트를 진행하면서 grid 공부를 많이 했다. 여러 포스팅 글과 MDN을 참고해서 포스팅해보려고한다! 구글링하지 않도록 여기에 최대한 모든 정보를 담아놔야지

 

 

CSS 그리드 레이아웃

그리드 레이아웃은 가로와 세로 두 방향의 2차원 레이아웃 시스템이다. flex는 1차원 레이아웃 시스템으로 차이점이 있다.

flex보다는 더 복합적인 레이아웃 디자인이 가능하다는 장점이 있고, MDN에 따르면 페이지에서 페이지로 이동할 때 요소가 널뛰거나 너비가 바뀌지 않는 디자인 생성에 도움을 주어 웹 사이트의 일관성을 높여준다는 장점이 있다고 한다.

 

크롬 개발자 도구에서 grid를 명확하게 보여주는 기능이 생겼다고한다! 이번 프로젝트 때 아주 잘 활용했다

 

 

 

CSS 그리드 생성

부모요소 👉🏻 그리드 컨테이너로 감싸고 있어야한다. 부모요소에 grid 속성을 주면 컨테이너는 그리드의 영향을 받는 전체의 영역이 된다.

자식요소 👉🏻 그리드 아이템들은 그리드 속성에 따라 자유롭게 배치된다.

 

 

 

CSS 그리드 용어

 

container 
gird 속성을 적용하는 부모 요소. grid 속성이 영향을 미치는 전체 영역이다.

item
컨테이너의 자식 요소들. 아이템들이 Grid 규칙에 의해 배치

cell
grid의 한칸

gap
셀 사이의 간격

column


row


line
그리드를 그리는 가로 세로의 선

area
그리드 유닛이 묶인 영역으로 고유한 식별자를 가지며, 식별자를 통해 요소를 배치

track
그리드 라인 사이의 행 또는 열

 

 

 

CSS 그리드 형태 정의

컨테이너에 Grid 트랙의 크기들을 지정해주는 속성이다.

grid-template-columns

grid-template-columns: 200px 200px 500px
grid-template-columns: 1fr 1fr 1fr 
grid-template-columns: repeat(3, 1fr) 
grid-template-columns: 200px 1fr 
grid-template-columns: 100px 200px auto

 

 

grid-template-rows

grid-template-rows: 200px 200px 500px
grid-template-rows: 1fr 1fr 1fr 
grid-template-rows: repeat(3, 1fr) 
grid-template-rows: 200px 1fr
grid-template-rows: 100px 200px auto

 

Tailwind CSS

/* col */
grid-cols-1	grid-template-columns: repeat(1, minmax(0, 1fr));
grid-cols-2	grid-template-columns: repeat(2, minmax(0, 1fr));
grid-cols-3	grid-template-columns: repeat(3, minmax(0, 1fr));
grid-cols-4	grid-template-columns: repeat(4, minmax(0, 1fr));
grid-cols-5	grid-template-columns: repeat(5, minmax(0, 1fr));


/* row */
grid-rows-1	grid-template-rows: repeat(1, minmax(0, 1fr));
grid-rows-2	grid-template-rows: repeat(2, minmax(0, 1fr));
grid-rows-3	grid-template-rows: repeat(3, minmax(0, 1fr));
grid-rows-4	grid-template-rows: repeat(4, minmax(0, 1fr));
grid-rows-5	grid-template-rows: repeat(5, minmax(0, 1fr));

 

grid-template-columns: repeat(3, 1fr)

repeat(반복횟수, 반복값)

grid-template-columns: 1fr 1fr 1fr 이 코드와 같은 뜻이다.

 

grid-template-columns: 1fr 1fr 1fr은 아래와 같다. fr은 fraction이라고하는데 잘모르겠다. 똑같은 비율로 주고싶을 때 이렇게 사용하면된다. 1:1:1 비율의 컬럼 3개라는 뜻. 여기서 위 코드를 더욱 간단하게 만들 수 있다.

 

 

auto-fill / auto-fit

auto-fill과 auto-fit은 column의 개수를 미리 정하지 않고 설정된 너비가 허용하는 한 최대한 셀을 채운다.

 

최소 너비는 20%로 지정, 그 이상일 경우 auto

auto-fill의 크기를 20%로 설정했으므로, 1개의 row에는 5개의 셀이 들어간다.

grid-template-columns: repeat(auto-fill, minmax(20%, auto));

 

auto-fill인 경우 row에 셀이 5개보다 적으면 공간을 남기고, auto-fit은 빈 공간을 모두 채운다는 차이점이 있다.

 

 

 

CSS 그리드 간격 Gap

Tailwind CSS

gap-0	    gap: 0px;
gap-x-0	    column-gap: 0px;
gap-y-0	    row-gap: 0px;
gap-px	    gap: 1px;
gap-x-px    column-gap: 1px;
gap-y-px    row-gap: 1px;
gap-0.5	    gap: 0.125rem; /* 2px */
gap-x-0.5   column-gap: 0.125rem; /* 2px */
gap-y-0.5   row-gap: 0.125rem; /* 2px */
gap-1	    gap: 0.25rem; /* 4px */
gap-x-1	    column-gap: 0.25rem; /* 4px */
gap-y-1	    row-gap: 0.25rem; /* 4px */

 

 

셀 영역 지정

  • grid-column-start
  • grid-column-end
  • grid-column
  • grid-row-start
  • grid-row-end
  • grid-row

Grid 아이템에 적용하는 속성으로, 각 셀의 영역을 지정

 

Tailwind CSS

col-start-1	grid-column-start: 1;
col-start-2	grid-column-start: 2;
col-start-3	grid-column-start: 3;
col-start-4	grid-column-start: 4;
col-start-5	grid-column-start: 5;

col-end-1	grid-column-end: 1;
col-end-2	grid-column-end: 2;
col-end-3	grid-column-end: 3;
col-end-4	grid-column-end: 4;
col-end-5	grid-column-end: 5;

col-auto	grid-column: auto;
col-span-1	grid-column: span 1 / span 1;
col-span-2	grid-column: span 2 / span 2;
col-span-3	grid-column: span 3 / span 3;
col-span-4	grid-column: span 4 / span 4;
col-span-5	grid-column: span 5 / span 5;

 

 

item  부터 보면 컬럼 그리드 라인 1부터 2까지 차지하고, 로우 1부터 5까지 차지해서 해당 컨테이너에서 왼쪽에 배치할 수 있다.

item  는 컬럼 2부터 5까지, 로우 4부터 5까지 차지해서 우측 아래 배치할 수 있다.

item  는 컬럼 3부터 5까지, 로우 2부터 4까지 차지해서 우측 중간에 배치할 수 있다.

 

 

최종 프로젝트 웹 ui이를 만들면서 모바일에선 없던 페이지네이션 기능이 생겼다. 좋아요 버튼부터 페이지네이션 기능까지 한번도 구현해보지 못했던 기능인데 이번 최종 프로젝트 때 뭔가 해보고싶었던 기능들은 다 해보는 것 같다. 

 

 

[React] Pagination 구현하기

프로젝트를 하면서 Pagination이 필요해 구현해 보기로 했다. 찾아보니 라이브러리도 여러 개 있던데 머리도 쓸 겸 그냥 직접 구현해 보기로 했다. Pagination의 동작 구조 전반적인 동작 구조는 네이

imdaxsz.tistory.com

 

[리액트] Tanstack Query 쿼리취소, 페이지네이션, 무한스크롤

1. Query Cancellation대용량 fetching을 중간에 취소하거나 사용하지 않는 컴포넌트에서 fetching이 진행 중이면 자동으로 취소시켜 불필요한 네트워크 비용을 줄일 수 있다queryFn 의 매개변수로 Abort Signal

ejunyang.tistory.com

 

 

 

해당 상품의 리뷰 데이터 가져오기

DB는 supabase를 사용하고 있다. 우선 각 상품의 리뷰데이터를 가져온다. 특산물 디테일 페이지는 url에 특산물 id값으로 가져온다. searchParams는 URL 객체의 속성으로, URL에 포함된 쿼리 문자열을 쉽게 다룰 수 있게 해주는 API이다.

 

만약 아래와 같은 코드라면 searchParams url의 값을 쉽게 가져올 수 있다.

const url = new URL('https://example.com?product_id=123&page=2');
const { searchParams } = url;

const productId = searchParams.get('product_id'); // '123'

const page = Number(searchParams.get('page')); // 2

 

 

요청된 데이터의 URL에서 쿼리 파라미터를 가져온다. supabase에서 range라는 범위 속성을 제공해준다.

(page - 1) * limit, page * limit - 1) 는 만약 페이지가 2, 리밋이 10일 경우 (2-1) * 10 = 시작 인덱스, 2 * 10 - 1 = 끝 인덱스 이렇게 한다면 시작 인덱스는 10, 끝 인덱스는 19가 된다. 하나의 페이지마다 10개의 데이터를 보여주는 것!

`/api/review?product_id=${productId}&page=${page}&limit=${limit}` //요청 url

const { searchParams } = new URL(request.url); //상대 경로는 URL 객체에서 직접 설정
const productId = searchParams.get('product_id'); // 쿼리 파라미터에서 product_id 가져오기
const page = Number(searchParams.get('page')) || 1; // 페이지 번호
const limit = Number(searchParams.get('product_id')) || 10; // 리뷰 10개씩
const { data: reviewData, error: reviewError } = await supabase
      .from('reviews')
      .select('*,users(avatar, name)')
      .eq('product_id', productId)
      .order('created_at', { ascending: false })
      .range((page - 1) * limit, page * limit - 1); // 페이지네이션

    if (reviewError) {
      return NextResponse.json({ error: reviewError.message }, { status: 400 });
    }
 

URL: searchParams property - Web APIs | MDN

The searchParams read-only property of the URL interface returns a URLSearchParams object allowing access to the GET decoded query arguments contained in the URL.

developer.mozilla.org

 

 

 

총 리뷰 데이터 가져오기

페이지네이션을 구현할 때 총 리뷰 개수를 알아야 전체 페이지 수를 계산할 수 있다. 사용자 경험으로 보았을 땐 사용자가 특정 페이지를 선택했을 때, 그 페이지가 유효한지 확인할 수 있다. 만약 총 데이터 수가 25개이고 페이지당 리밋이 10개라면 2.5페이지가 나와야하는데 이때 Math.ceil() 사용해 정수로 바꿔주면서 3페이지로 올림처리한다.

const { count: totalReviews, error: countError } = await supabase
      .from('reviews')
      .select('*', { count: 'exact' }) // 총 행 수 계산 => 반환, count 속성에 총 리뷰 수
      .eq('product_id', productId);

    if (countError) {
      return NextResponse.json({ error: countError.message }, { status: 400 });
    }

    const totalPages = Math.ceil((totalReviews || 0) / limit); // 총 페이지 수 계산

 

응답 값

 

 

 

데이터 패칭

쿼리키에 데이터를 저장해줬다. fetch를 사용해서 API엔드포인트에 GET요청을 보내고, 응답을 JSON으로 변환해서 반환해준다. 여기서 productId는 리뷰를 가져올 특정 상품의 id이다.

  const [currentPage, setCurrentPage] = useState(1);
  const limit = 10;

  const fetchReview = async (page: number) => {
    const response = await fetch(
      `/api/review?product_id=${productId}&page=${page}&limit=${limit}`
    );
    const data = await response.json();
    return data;
  };

  const {
    data: reviewData,
    error,
    isPending
  } = useQuery<ReviewDataType>({
    queryKey: ['reviews', productId, currentPage],
    queryFn: () => fetchReview(currentPage)
  });

 

사용자가 페이지를 변경하면 setCurrentPage를 호출해 currentPage를 업데이트 해준다 현재 페이지가 업데이트되면 useQuery가 다시 실행되고 fetchReview를 통해 새로운 데이터를 가져온다.

  const handlePageChange = (page: number) => {
    setCurrentPage(page);
  };

 

 

페이지네이션 UI

페이지네이션 ui는 shadcn을 사용했다. shadcn은 쓰면 쓸수록 좋은 것 같다. 간단하고 예쁘고..~

PaginationPrevious는 현재 페이지가 1보다 클때 이전 페이지로 이동할 수 있다. 

PaginationNext는 리뷰 총 페이지보다 작을 때 다음 페이지로 이동할 수 있다.

 

Array.from은 reviewData.totalPages 만큼 배열을 생성한다. 현재 총 페이지는 2페이지이다. onClick={() => handlePageChange(index + 1)} 클릭한 페이지 번호를 인자로 받아서 현재 페이지를 변경한다. isActive={currentPage === index + 1} 현재 페이지와 클릭한 페이지 번호가 일치하면 active한 상태를 보여준다. 

  <Pagination>
        <PaginationContent>
          <PaginationItem>
            <PaginationLink onClick={() => handlePageChange(currentPage - 1)}>
              <button className="flex items-center gap-[6px] text-label-assistive text-[15px]">
                <RiArrowLeftDoubleFill />
                처음
              </button>
            </PaginationLink>
          </PaginationItem>
          <PaginationItem>
            <PaginationPrevious
              onClick={() =>
                currentPage > 1 && handlePageChange(currentPage - 1)
              }
            />
          </PaginationItem>
          {Array.from({ length: reviewData.totalPages }, (_, index) => (
            <PaginationItem key={index + 1}>
              <PaginationLink
                href="#"
                onClick={() => handlePageChange(index + 1)}
                isActive={currentPage === index + 1}
              >
                {index + 1}
              </PaginationLink>
            </PaginationItem>
          ))}
          <PaginationItem>
            <PaginationNext
              onClick={() =>
                currentPage < reviewData.totalPages &&
                handlePageChange(currentPage + 1)
              }
            />
          </PaginationItem>
          <PaginationItem>
            <PaginationLink
              onClick={() => handlePageChange(reviewData.totalPages)}
            >
              <button className="flex items-center gap-[6px] text-label-assistive text-[15px]">
                마지막
                <RiArrowRightDoubleFill />
              </button>
            </PaginationLink>
          </PaginationItem>
        </PaginationContent>
      </Pagination>

 

 

Pagination

Pagination with page navigation, next and previous links.

ui.shadcn.com

 

 

 

완성

페이지 버튼을 눌렀을 때 스크롤이 다시 위로 올라간다. 리뷰 콘텐츠 부분만 변경되게 하고싶은데.. 이 부분은 추후 수정해야겠다.

 

 

프로젝트를 하면 할수록 내가 맡은 기능에 빈틈이 보여 계속 수정하게된다. 에러로직이나 선택한 상품이 없는 하단에 상품 개수가 보인다던가. 특정 상품만 체크하고 수량을 올렸더니 체크가 모두 활성화된다던가 이런 빈틈들이 잘보이고 유저테스트 이후로 기능 구현을 섬세하게 해야한다는게 얼마나 중요한지 뼈저리게 느꼈다ㅎ 오늘은 체크한 상품들을 선택 삭제하는 기능 구현을 해보려고한다!!

 

 

 

 

DeleteButton.tsx

선택 삭제 버튼을 따로 만들어주었다. 선택 삭제를 염두해두고 상품 삭제하는 로직이 있는 커스텀 훅을 만들어놓아서 코드를 간단하게 짤 수 있었다. 여기서 마주친 문제.. 처음 코드를 쓸 때 map을 사용했었다.

import { useDeleteProduct } from '@/hooks/localFood/useDeleteProduct';

interface ButtonProps {
  selectedItems: string[];
  setSelectedItems: React.Dispatch<React.SetStateAction<string[]>>;
}

export const DeleteButton = ({
  selectedItems,
  setSelectedItems
}: ButtonProps) => {
  const mutation = useDeleteProduct();

  const handleSelectedDelete = () => {
    selectedItems.map((productId) => {
      mutation.mutate(productId);
      return null;
    });

    setSelectedItems([]);
  };

  return (
    <button
      onClick={handleSelectedDelete}
      className="text-base text-label-alternative font-normal"
    >
      선택 삭제
    </button>
  );
};

 

 

그런데 생각해보니까 map은 새로운 배열을 반환하는데 상품을 삭제하는데 굳이? 새 배열을 반환할 필요가 있을까 생각이 들었다. 괜한 메모리 낭비같았다 그래서 다른 메서드를 구글링하다가 forEach 가 적합하겠다고 생각했다 분명 배운건데 코드짤 땐 생각이 안날까,,

 

 

 

forEach와 map의 차이점 → 새로운 배열 생성 반환여부

forEach()

함수는 배열의 각 요소를 순회하며 주어진 함수를 호출합니다. 각 요소에 대해 함수를 호출할 때, 해당 요소의 값, 인덱스, 그리고 원본 배열을 인수로 넘겨줍니다. 주어진 함수는 배열의 크기만큼 반복 실행되며, 배열 요소의 개수에 따라 콜백 함수가 호출되는 횟수가 결정됩니다.

forEach() 함수는 반환 값이 항상 undefined 입니다. 새로운 배열을 생성하지 않습니다.

 

map()

map() 함수는 배열을 순회해서 각 요소를 콜백 함수로 적용해서 처리해 모은 새로운 배열을 반환하기 위한 함수입니다.

map() 함수에 전달되는 콜백 함수는 "각 요소를 변환하여 새로운 배열로 매핑(mapping)하는 역할을 한다"라고 말합니다.
이렇게 매핑된 결과를 새로운 배열로 반환하기 때문에 이 함수의 이름이 "map"으로 정해졌습니다.

 

※ 참고

 

[Javascript] 다양한 배열 메서드(순회, 반복)를 알아보자

1. forEach forEach는 단순히 배열을 반복합니다. const arr = [2, 4, 6, 8, 10]; arr.forEach((value, index) => { console.log(`${value} : ${index}`) }); // 실행 결과 // 2 : 0 // 4 : 1 // 6 : 2 // 8 : 3 // 10 : 4 forEach문은 break 문이 없기

lejh.tistory.com

 

 

 

자바스크립트 forEach() 함수 – 개념 정리 및 사용 예제 - 코딩에브리바디

자바스크립트의 forEach() 함수는 배열을 순회해서 각 요소를 콜백 함수로 처리하기 위한 함수입니다. 배열의 각 요소에 대해 주어진 콜백 함수를 적용해서 순서대로 한 번씩 실행합니다.

codingeverybody.kr

 

 

 

수정코드

forEach로 짜면 원본 배열에 대한 작업을 수행해서 새로운 배열을 생성하지 않아 메모리 차지를 하지 않는다는 장점이 있다!

import { useDeleteProduct } from '@/hooks/localFood/useDeleteProduct';

interface ButtonProps {
  selectedItems: string[];
  setSelectedItems: React.Dispatch<React.SetStateAction<string[]>>;
}

export const DeleteButton = ({
  selectedItems,
  setSelectedItems
}: ButtonProps) => {
  const mutation = useDeleteProduct();

  const handleSelectedDelete = () => {
    selectedItems.forEach((productId) => {
      mutation.mutate(productId);
    });

    setSelectedItems([]);
  };

  return (
    <button
      onClick={handleSelectedDelete}
      className="text-base text-label-alternative font-normal"
    >
      선택 삭제
    </button>
  );
};

 

 

 

적용

 

 

아래 코드는 디테일 페이지에서 특정 상품의 데이터를 가져오는 부분이다. 원래는 상세 정보와 리뷰 탭이 없었는데 UT를 진행하면서 리뷰가 있었으면 좋을 것 같다는 피드백이 많아서 구현하게 되었다. UT를 해볼 기회는 없었는데 이번에 해보게 되어서 너무 좋았다(?) 피드백을 받고 수정하는데 테스트 전보다 완성도 높은 작업물이 나올 것 같다.

const {
    data: food,
    isPending,
    error
  } = useQuery({
    queryKey: ['localfood', id],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('local_food')
        .select('*')
        .eq('product_id', id)
        .single();

      if (error) throw new Error(error.message);

      return data;
    }
  });

 

 

그런데 여기서 문제. 저 리뷰 탭에 리뷰의 개수가 보여야하는데 저 개수만 보여주려고 리뷰 데이터를 패치해야하는 상황이 생겼다. 라우터 핸들러로 만들어놓긴 했지만 저 개수를 띄우려고 코드를 몇줄 이상 써야하는게 비효율적이라고 생각이 들었다. 구글링하다가 supabase join 기능이 있다고해서 참고했다. supabase 초반에 사용할 때 들었던 기능 같은데, 프로젝트에서 써먹어본건 최종까지와서다.. 막상 기억이 잘 안나서 사용하지 못했다,, 이제라도 알아서 다행이다^.~

 

Querying Joins and Nested tables | Supabase Docs

The Data APIs automatically detect relationships between Postgres tables.

supabase.com

 

supabase join 으로 여러 데이터 한번에 가져오기

최근 프로젝트에서 팔로워 리스트를 가져오는 기능을 구현하던 중, 나를 팔로우한 유저의 프로필 정보를 가져오는 과정이 너무 복잡하고 비효율적이라는 생각이 들었다. 그래서 리팩토링을 진

velog.io

 

 

수정코드

리뷰 테이블의 모든 데이터를 가져왔다.

const {
    data: food,
    isPending,
    error
  } = useQuery({
    queryKey: ['localfood', id],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('local_food')
        .select('*, reviews(*)')
        .eq('product_id', id)
        .single();

      if (error) throw new Error(error.message);

      return data;
    }
  });

 

사용할 땐 두개의 테이블 이름을 같이 써서 아래와 같이 사용하면 된다.

    <li
            className="flex-1 cursor-pointer"
            onClick={() => setActiveTab('리뷰')}
          >
            <p
              className={`pb-2 w-[140px] mx-auto ${
                activeTab === '리뷰'
                  ? 'text-primary-20 border-b-4 border-primary-20'
                  : 'text-label-assistive'
              }`}
            >
              {`리뷰(${food.reviews.length})`}
            </p>
          </li>

 

 

[프로젝트] Next.js 장바구니 구현하기 - (4)

가현님 덕에 최고심에 빠져버렸다.. 채김져.. 미루고 미루던 삭제 구현하기. 그래도 수량 버튼을 useMutation으로 처리하면서 힌트를 얻어서 삭제도 useMutation을 통해 데이터를 업데이트하려고 한다.

ejunyang.tistory.com

 


 

기존 구조

삭제까지 모두 완료해서 체크 상품만 결제 되도록 해보려고한다. 처음엔 체크 전에도 장바구니에 담긴 상품의 결제금액이 바로 브라우저에 보였는데 체크 전까진 0원으로 보이도록 하려고한다.

 

처음 구조는 아래와 같다. CartList는 장바구니 테이블을 보여주는 페이지이다. 그 아래 DataTable과 CartFixedButtons를 임포트하고 있는데 여기서 내가 사용한 shadcn의 데이터 테이블 컴포넌트는 컬럼과 데이터 테이블 파일구조를 분리해서 쓰는 것을 추천해서 그렇게 사용했다. 

 

 

 

 

 

 

shadcn의 데이터 테이블 컴포넌트를 처음에 쓸 때 생소했던게 컬럼 컴포넌트는 함수형 컴포넌트가 아니라 배열만 덜렁 들어있었다. 컬럼을 추가하면 테이블에 데이터를 추가할 수 있었지만 함수형 컴포넌트가 아니라 따로 상태로 관리해주거나 하기 힘들었다. 그래서 수량 변경이나 삭제 같은 경우에도 새로고침을 해야 브라우저에 반영됐다. 그래서 아래와 같이 구조를 변경했다.

 

 

 

 

📌 shadcn 제공 코드

DataColumns.tsx

"use client"

import { ColumnDef } from "@tanstack/react-table"

// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type Payment = {
  id: string
  amount: number
  status: "pending" | "processing" | "success" | "failed"
  email: string
}

export const columns: ColumnDef<Payment>[] = [
  {
    accessorKey: "status",
    header: "Status",
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "amount",
    header: "Amount",
  },
]

 

DataTable.tsx

여기서 coulms을 porps로 받아서 테이블에 데이터를 보이게 하는것.

"use client"

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table"

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[]
  data: TData[]
}

export function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                )
              })}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  )
}

 

 

 

CartList.tsx

내가 선택한 상품의 금액만 CartPriceList에 나와야한다. 여기서 투두리스트와 같은 방식으로 하면 되겠다고 생각했다. Done 영역엔 진행 완료된 투두리스트가 들어가도록하는 로직!! 해다당 유저가 담은 카트 데이터를 가져와서 상태로 관리해주었다.

  const [selectedItems, setSelectedItems] = useState<string[]>([]);
  const { cartData, isPending, error } = useUserCartData();
  const text = '장바구니가 비었어요';

  if (isPending) return <Loading />;
  if (error) return <div>오류 {error.message}</div>;

  return (
    <div>
      {cartData ? (
        <TableDataColumns
          selectedItems={selectedItems}
          setSelectedItems={setSelectedItems}
        />
      ) : (
        //장바구니 비어있을 경우 디폴트 이미지 표시
        <DefaultImage text={text} />
      )}

      <CartFixedButtons data={cartData ?? []} selectedItems={selectedItems} />
    </div>
  );
};

 

 

Data-table-column-header.tsx

CartList에서 장바구니 데이터에서 id 값만 담은 selectedItem를 props로 받아 체크 선택과 해제하는 코드를 구현했다. 여기서 onCheckedChange에 value는 체크가 됐는지 안됐는지 boolean 값을 가지고있다. 

export const TableDataColumns = ({
  selectedItems,
  setSelectedItems
}: TableProps) => {
  const { cartData, isPending, error } = useUserCartData();

  if (isPending) return <Loading />;
  if (error) return <div>오류 {error.message}</div>;

  const columns: ColumnDef<CartItem>[] = [
    {
      //전체선택
      id: 'select',
      header: ({ table }) => (
        <div className="flex items-center whitespace-nowrap">
          <Checkbox
            checked={
              table.getIsAllPageRowsSelected() ||
              (table.getIsSomePageRowsSelected() && 'indeterminate')
            }
            onCheckedChange={(value) => {
              //console.log(value);
              const allSelectedItems = value
                ? cartData?.map((item) => item.product_id)
                : [];
              setSelectedItems(allSelectedItems as string[]);
              table.toggleAllPageRowsSelected(!!value);
            }}
            aria-label="Select all"
          />
          <div className="text-base text-label-strong ml-2 absolute left-10">
            {`전체 선택 (${table.getFilteredSelectedRowModel().rows.length}/${
              table.getFilteredRowModel().rows.length
            })`}
          </div>
        </div>
      ),
      //부분선택
      cell: ({ row }) => (
        <Checkbox
          checked={
            selectedItems.length > 0
              ? selectedItems.includes(row.getValue('product_id'))
              : false
          }
          onCheckedChange={(value) => {
            setSelectedItems((prev) => {
              if (value) {
                return [...prev, row.getValue('product_id')];
              } else {
                return prev.filter((id) => id != row.getValue('product_id'));
              }
            });
          }}
          aria-label="Select row"
          style={{ transform: 'translate(0, -130%)' }}
        />
      ),
      enableSorting: false,
      enableHiding: false
    },
  ];
  return (
    <DataTable
      columns={columns}
      data={cartData ?? []}
      selectedItems={selectedItems}
    />
  );
};

 

체크 선택과 해제 로직은 아래 코드를 참고했다.

 

Checkbox

A control that allows the user to toggle between checked and not checked.

ui.shadcn.com

 

 

CartPriceList.tsx

기존 코드에서 추가한건 if (selectedItems.includes(item.product_id ?? '')) 이것뿐이다. 

'use client';
import { Tables } from '@/types/supabase';
import { useEffect, useState } from 'react';

interface CartProps {
  data: Tables<'cart'>[] | null;
  selectedItems: string[];
}
const DELIVERY_FEE = 2500;
const COUPON = 2000;

export const CartPriceList = ({ data, selectedItems }: CartProps) => {
  const [totalAmount, setTotalAmount] = useState(0);

  useEffect(() => {
    const calculator =
      data?.reduce((acc, item) => {
        if (selectedItems.includes(item.product_id ?? '')) {
          const price = item.product_price ?? 0;
          const quantity = item.count ?? 0;
          return acc + price * quantity;
        }
        return acc;
      }, 0) || 0;
    setTotalAmount(calculator);
  }, [data, selectedItems]);

  if (!data || (data.length === 0 && !selectedItems)) {
    return null;
  }

  //총 결제금액
  const totalPrice = totalAmount + DELIVERY_FEE - COUPON;
};

 

 

 

CartFixedButtons.tsx

<PayButton product={product} orderNameArr={orderNameArr} /> product에서 아래와 같은 오류가 났다. 타입 오류인데 null 값이 포함된 배열이 '{ name: string | null; amount: number; quantity: number; id?: string | undefined; }' 이런 특정 타입에 할당될 수 없다는 것 같다. 오늘도 난 구글링을 한다..,,

'({ name: string | null; amount: number; quantity: number; } | null)[]' 형식은 'Products' 형식에 할당할 수 없습니다. '{ name: string | null; amount: number; quantity: number; } | null' 형식은 '{ name: string | null; amount: number; quantity: number; id?: string | undefined; }' 형식에 할당할 수 없습니다. 'null' 형식은 '{ name: string | null; amount: number; quantity: number; id?: string | undefined; }' 형식에 할당할 수 없습니다.
ts(2322)
export const CartFixedButtons = ({ data, selectedItems }: CartButtonProps) => {
  const totalPrice =
    data?.reduce((acc, item) => {
      if (selectedItems.includes(item.product_id ?? '')) {
        const price = item.product_price ?? 0;
        const quantity = item.count ?? 0;
        return acc + price * quantity;
      }
      return acc;
    }, 0) || 0;

  const totalAmount = totalPrice + DELIVERY - COUPON;

  const orderNameArr = data
    .map((item) => {
      if (selectedItems.includes(item.product_id as string)) {
        return item.product_name;
      }
      return null;
    })
     .filter((name): name is string => name !== null);

  // 전달 데이터 형식
  // {
  //   name: "청송 사과",
  //   amount: 8000,
  //   quantity: 3,
  // }

  const product = data
    .map((item) => {
      if (selectedItems.includes(item.product_id as string)) {
        return {
          name: item.product_name,
          amount: (item.product_price ?? 0) * (item.count ?? 0),
          quantity: item.count ?? 0
        };
      }
      return null; //장바구니 선택 상품 외 null 처리
    })

 

 

타입가드로 is를 사용했는데 사용한 이유는 특정 타입을 체크하는 함수에서 반환되는 값이 true 이면 해당 함수를 사용하는 블록 안에서도 인자로 받은 값의 타입을 특정 타입으로 확정시켜 줄 수 있도록 하는 기능을 한다고한다. 원래 특정 변수를 함수로 넘겨서 타입을 체크하게 되면 그 함수를 사용하고 있는 스코프에서는 타입 축소가 안되어 불편했었는데, 이 키워드를 활용하면 그 부분을 해결 할 수 있다고해서 사용했다 ㅎ is 는 typescript 에서 지원하고 있는 키워드라고 한다.

 

타입 가드와 타입 축소에 대하여

안녕하세요! 최근에 타입 가드와 타입 축소에 대한 용어를 처음 들어봐서 해당 용어에 대해 알아보려고 합니다. 타입 가드 타입 가드(Type Guard)는 컴파일러가 타입을 예측할 수 있도록 타입의 범

funveloper.tistory.com

 

 

수정 코드

item이 null이 아닌 경우에만 해당 객체의 타입으로 축소될 수 있도록 했다.

  const product = data
    .map((item) => {
      if (selectedItems.includes(item.product_id as string)) {
        return {
          name: item.product_name,
          amount: (item.product_price ?? 0) * (item.count ?? 0),
          quantity: item.count ?? 0
        };
      }
      return null; //장바구니 선택 상품 외 null 처리
    })
    .filter(
      (
        item
        //타입 에러 : 타입 가드로 타입 축소
      ): item is { name: string | null; amount: number; quantity: number } =>
        item != null
    );

 

 

 

 

프로젝트 적용

우하하 완성!

 

🔗 swiper API

 

Swiper - The Most Modern Mobile Touch Slider

Swiper is the most modern free mobile touch slider with hardware accelerated transitions and amazing native behavior.

swiperjs.com

 

 

 


 

 

설치

yarn add swiper

 

 

Import

8버전 이하는 import의 경로가 다를 수 있으니 확인해봐야한다. 가장 최신 버전은 아래와 같다.

import { Swiper, SwiperSlide } from 'swiper/react';

 

module import

import { Pagination } from 'swiper/modules';
더보기
  • Virtual - 가상 슬라이드 모듈
  • Keyboard - 키보드 제어 모듈
  • Mousewheel - 마우스 휠 제어 모듈
  • Navigation - 내비게이션 모듈
  • Pagination - 페이지네이션 모듈
  • Scrollbar - 스크롤바 모듈
  • Parallax - 패럴렉스 모듈
  • FreeMode - 자유 모드 모듈
  • Grid - 그리드 모듈
  • Manipulation - 슬라이드 조작 모듈 (코어 버전 전용)
  • Zoom - 확대 모듈
  • Controller - 컨트롤러 모듈
  • A11y - 접근성 모듈
  • History - 히스토리 내비게이션 모듈
  • HashNavigation - 해시 내비게이션 모듈
  • Autoplay - 자동 재생 모듈
  • EffectFade - 페이드 효과 모듈
  • EffectCube - 큐브 효과 모듈
  • EffectFlip - 플립 효과 모듈
  • EffectCoverflow - 커버플로우 효과 모듈
  • EffectCards - 카드 효과 모듈
  • EffectCreative - 크리에이티브 효과 모듈
  • Thumbs - 썸네일 모듈

css import

// Import Swiper styles
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/pagination';
import 'swiper/css/scrollbar';
더보기
  • swiper/css/a11y - 접근성 모듈에 필요한 스타일
  • swiper/css/autoplay - 자동 재생 모듈에 필요한 스타일
  • swiper/css/controller - 컨트롤러 모듈에 필요한 스타일
  • swiper/css/effect-cards - 카드 효과 모듈에 필요한 스타일
  • swiper/css/effect-coverflow - 커버플로우 효과 모듈에 필요한 스타일
  • swiper/css/effect-creative - 크리에이티브 효과 모듈에 필요한 스타일
  • swiper/css/effect-cube - 큐브 효과 모듈에 필요한 스타일
  • swiper/css/effect-fade - 페이드 효과 모듈에 필요한 스타일
  • swiper/css/effect-flip - 플립 효과 모듈에 필요한 스타일
  • swiper/css/free-mode - 자유 모드 모듈에 필요한 스타일
  • swiper/css/grid - 그리드 모듈에 필요한 스타일
  • swiper/css/hash-navigation - 해시 내비게이션 모듈에 필요한 스타일
  • swiper/css/history - 히스토리 모듈에 필요한 스타일
  • swiper/css/keyboard - 키보드 모듈에 필요한 스타일
  • swiper/css/manipulation - 조작 모듈에 필요한 스타일
  • swiper/css/mousewheel - 마우스 휠 모듈에 필요한 스타일
  • swiper/css/navigation - 내비게이션 모듈에 필요한 스타일
  • swiper/css/pagination - 페이지네이션 모듈에 필요한 스타일
  • swiper/css/parallax - 패럴렉스 모듈에 필요한 스타일
  • swiper/css/scrollbar - 스크롤바 모듈에 필요한 스타일
  • swiper/css/thumbs - 썸네일 모듈에 필요한 스타일
  • swiper/css/virtual - 가상 모듈에 필요한 스타일
  • swiper/css/zoom - 확대 모듈에 필요한 스타일

 

컴포넌트

export default () => {
  return (
    <Swiper
      spaceBetween={50}
      slidesPerView={3}
      onSlideChange={() => console.log('slide change')}
      onSwiper={(swiper) => console.log(swiper)}
    >
      <SwiperSlide>Slide 1</SwiperSlide>
      <SwiperSlide>Slide 2</SwiperSlide>
      <SwiperSlide>Slide 3</SwiperSlide>
      <SwiperSlide>Slide 4</SwiperSlide>
      ...
    </Swiper>
  );
};

 

옵션

modules={[Pagination, Autoplay]} : 사용할 모듈

spaceBetween : 슬라이드 사이 간격(margin)
slidesPerView : 보여질 슬라이드 수
centeredSlides: 센터 모드
pagination={{ clickable: true }} : 페이지네이션 버튼을 눌렀을 때 이동 가능
navigation : Prev, Next 버튼
autoplay={{ delay: 2000, disableOnInteraction: false }} : 자동 재생 / 지연시간 / 사용자 상호작용시 슬라이더 일시 정지 비활성
loop={true} : 반복여부

 

 

프로젝트 적용

스타일은 tailwind css를 사용하고 있어서 global.css에 최소한의 스와이퍼 슬라이드 스타일을 적용해줬다. 페이지네이션(인디케이터)에 스타일을 적용해줘야했기때문도 있다 ㅎ 의문점은 페이지네이션 스타일을 페이지마다 다르게 할 순 없을까..? 모듈로 들어가 있어서 스타일을 어떻게 먹여줘야할지 잘모르겠다.

/* 슬라이드 공통 css */
.swiper {
  width: 100%;
  height: 100%;
}

.swiper-slide {
  text-align: left;
  border-top-right-radius: 12px;
  border-top-left-radius: 12px;

  /* Center slide text vertically */
  display: flex;
  justify-content: center;
  align-items: center;
}

.swiper-slide img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.swiper-pagination {
  width: 164px !important;
  background-color: rgba(232, 193, 142, 0.64) !important;
  border-radius: 8px;
  padding: 5px 20px;
}

.swiper-horizontal > .swiper-pagination-bullets,
.swiper-pagination-bullets.swiper-pagination-horizontal {
  left: 50% !important;
  transform: translateX(-50%);
}

.swiper-pagination-bullet-active {
  background-color: rgb(117, 84, 40) !important;
  width: 64px !important;
  border-radius: 4px !important;
}

.swiper-pagination-bullet {
  background-color: rbg(189, 135, 63) !important;
}

 

 

SwiperSlide

메인 페이지 시장 섹션은 슬라이드로 구현되어야했기 때문에 아래와 같이 사용해보았다.

 {images?.slice(0, 4).map((item, index) => (
        <SwiperSlide key={index} className="!w-[311px]">
          <div>
            {item.이미지 ? (
              <Image
                src={`${item.이미지[0]?.link}`}
                width={454}
                height={340}
                priority
                alt={`Slide ${index + 1}`}
                style={{
                  height: 340,
                  border: '3px solid #9C6D2E',
                  borderTopLeftRadius: '12px',
                  borderTopRightRadius: '12px'
                }}
              />
            ) : (
              <>없음</>
            )}
          </div>
          <div className="bg-primary-20 text-label-light p-4 rounded-br-[12px] rounded-bl-[12px]">
            <div className="flex justify-between items-center">
              <h2 className="text-lg font-semibold">{item.시장명}</h2>
              <LikeButton />
            </div>
            <div className="text-sm mt-2 leading-[22.4px]">
              <p>{item['시장 유형']}</p>
              <p>{item.도로명주소}</p>
            </div>
          </div>
        </SwiperSlide>

 

 

 

완성

메인에 시장섹션뿐만 아니라 배너에도 사용했고, 특산물 상세페이지의 상단 이미지도 슬라이드로 구현했다!

 

 

가현님 덕에 최고심에 빠져버렸다.. 채김져.. 미루고 미루던 삭제 구현하기. 그래도 수량 버튼을 useMutation으로 처리하면서 힌트를 얻어서 삭제도 useMutation을 통해 데이터를 업데이트하려고 한다. 

 

🧺 장바구니 1단계

 

[프로젝트] Next.js 장바구니 구현하기 - (1)

이번주 월요일부터 개발작업에 들어갔다. 와이어프레임이 거의 나와서(혜원님의 열일) 개발을 바로 시작할 수 있었다. 내가 맡은 기능은 아래와 같다. 우선 월요일에 특산물 전체보드와 상세페

ejunyang.tistory.com

🧺 장바구니 2단계

 

[프로젝트] Next.js 장바구니 구현하기 - (2)

장바구니에 담은 상품 수량 추가/감소 기능 구현하던 중 추가하면 아래 결제 금액도 변경이 되어야하는데 변경이 되지 않고 새로고침해야 변경이 되었다.. useState로 화면에 바로 렌더링되도록

ejunyang.tistory.com

🧺 장바구니 3단계

 

[프로젝트] Next.js 장바구니 구현하기 - (3)

수량을 누르면 총 결제금액이 오르는 것까지 구현했다. 근데 수량 버튼을 눌렀을 때 좀 느리게 브라우저에 반영되어서 optimsitic update로 구현해보려고한다.  [프로젝트] Next.js 장바구니 구현하기

ejunyang.tistory.com

 


 

Data-table-column-header.tsx

원래는 아래와 같은 로직으로 진행했다. 하지만 새로고침해야 삭제가 되었다.

Data-table을 임포트하고있는 CartList 컴포넌트에서 이미 카트 데이터를 useQuery로 관리하고 있기 때문에 업데이트는 모두 useMutation을 사용해주기로했다.

const deleteProduct = async (productId: string) => {
  const { data, error } = await supabase
    .from('cart')
    .delete()
    .eq('product_id', productId);
  if (error) {
    console.error('상품을 삭제하는데 실패했습니다.', error);
  } else {
    return data;
  }
};

 

 

 

이전에 카운트 버튼을 참고해서 구현했다. useDeleteProduct 훅을 생성하고 아래 쿼리문을 썼다. 원래는 함수로 만들었는데 빌드 테스트할때 에러가 났다. useQueryClient 훅이 deleteProduct 함수 안에서 호출되는 것은 React 훅 규칙에 위배된다는 에러였고, 훅은 반드시 React 함수 컴포넌트 또는 사용자 정의 훅 내에서만 호출해야한다는 오류였다.

Error: React Hook "useQueryClient" is called in function "deleteProduct" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use". react-hooks/rules-of-hooks

 

 

그래서 따로 useDeleteProduct 훅 파일을 만들어 빼놓고 임포트 시켰다.

const useDeleteProduct = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (productId: string) => {
      const { error } = await supabase
        .from('cart')
        .delete()
        .eq('product_id', productId);

      if (error) {
        throw new Error('상품을 삭제하지 못했습니다.' + error.message);
      }
    },
    onSuccess: () => {
       queryClient.invalidateQueries({
         queryKey: ['cart']
       });
     }
 })

 

 

데이터는 잘 삭제되지만 이것도 수량을 올릴때 결제 금액이 천천히 올랐던 것처럼 느리게 반영되는 이슈 발생.. 이것도 낙관적 업데이트를 해줘야하나보다.. useMutation으로 서버의 데이터를 수정할 땐 꼭 optimistic update를 해줘야할 것 같다.

 {
    id: 'delete',
    header: '',
    cell: ({ row }) => {
      const mutation = useDeleteProduct();

      return (
        <button onClick={() => mutation.mutate(row.getValue('product_id'))}>
          <CgClose className="text-[#959595]" />
        </button>
      );
    }
  }

 

여기서 또 나타난 빌드에러..☠️  columns가 배열로 되어있어서 const mutation = useDeleteProduct(); 을 컬럼 배열 안에 넣는 것은 훅 규칙에 위배된다는 말이었다. 훅은 반드시 리액트 컴포넌트 안에 호출해야한다고 한다.

Error: React Hook "useDeleteProduct" cannot be called at the top level. React Hooks must be called in a React function component or a custom React Hook function. react-hooks/rules-of-hooks

 

 

그래서 삭제버튼 컴포넌트를 만들어서 컬럼 배열에 추가해주었다.

const DeleteButton = ({ productId }: { productId: string }) => {
  const mutation = useDeleteProduct();

  const handleDelete = () => {
    if (confirm('해당 제품을 삭제하시겠습니까?')) {
      mutation.mutate(productId);
    }
  };

  return (
    <button onClick={handleDelete}>
      <CgClose className="text-[#959595]" />
    </button>
  );
};
{
    id: 'delete',
    header: '',
    cell: ({ row }) => {
      return <DeleteButton productId={row.getValue('product_id')} />;
    }
  }

 

 

Optimisitic Update

 

[프로젝트] Next.js 장바구니 구현하기 - (3)

수량을 누르면 총 결제금액이 오르는 것까지 구현했다. 근데 수량 버튼을 눌렀을 때 좀 느리게 브라우저에 반영되어서 optimsitic update로 구현해보려고한다.  [프로젝트] Next.js 장바구니 구현하기

ejunyang.tistory.com

 

기존에 있었던 onSuccess는 지우고 onMutate, onError, onSettled 조합을 추가했다. 이미 만들어 놓은 카운터 컴포넌트에서 그대로 가져와서 사용했고, 삭제하는 로직만 변경했다.

onMutate: async (productId: string) => {
      await queryClient.cancelQueries({
        queryKey: ['cart']
      }); // 기존 쿼리 취소

      const previousCart = queryClient.getQueryData(['cart']); // 기존 데이터 저장

      // optimistic update
      queryClient.setQueryData(['cart'], (oldData: CartItem[] = []) => {
        return oldData.filter((item) => item.product_id !== productId);
      });

      return { previousCart }; // 롤백을 위한 이전 데이터 반환
    },

 

 

 

 

프로젝트 적용

 

 

수량을 누르면 총 결제금액이 오르는 것까지 구현했다. 근데 수량 버튼을 눌렀을 때 좀 느리게 브라우저에 반영되어서 optimsitic update로 구현해보려고한다. 

 

[프로젝트] Next.js 장바구니 구현하기 - (2)

장바구니에 담은 상품 수량 추가/감소 기능 구현하던 중 추가하면 아래 결제 금액도 변경이 되어야하는데 변경이 되지 않고 새로고침해야 변경이 되었다.. useState로 화면에 바로 렌더링되도록

ejunyang.tistory.com

 

 

 

Optimistic update(낙관적 업데이트)

말 그대로 낙관적이다. 서버에 요청을 보내고 됐겠지~~~하고 화면에 적용해버리는 것. 서버 요청이 정상임을 가정하고 더 나은 UX를 제공할 수 있다. 예시로 좋아요 버튼을 구현할 때 많이 사용한다. 시간이 걸린다는 단점을 해결하기 위해 고려된 방법인 만큼 네트워크가 느린 환경에서 유용하다는 장점이 있으나, 서버와의 데이터가 다를 수 있다는 단점이 있으므로 이를 해결하기 위해 서버와 클라이언트 간에 상태가 같은지 최종 확인 과정을 거쳐야만 한다.

 

 

Optimistic Updates | TanStack Query React Docs

React Query provides two ways to optimistically update your UI before a mutation has completed. You can either use the onMutate option to update your cache directly, or leverage the returned variables to update your UI from the useMutation result. Via the

tanstack.com

 


 

CountButton.tsx

원래 코드는 아래와 같다. useMutation으로 데이터 업데이트를 해주었고 업데이트에 성공할 경우 invalidateQueries를 사용해 무효화해서 화면해 새로운 데이터를 가져와서 보여주었다. 서버에 요청을 보내고 받아와서 화면에 반영하는데까지 오래걸려서 낙관적 업데이트 기술을 사용해 바로바로 수량이 올라가고 결제 금액이 반영될 수 있도록 코드를 수정해보았다.

const addCountMutation = useMutation({
    mutationFn: async () => {
      const newCount = counts + 1;
      await updateCountInDatabase(newCount);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({
        queryKey: ['cart']
      });
    }
  });

 

 

 

 

낙관적 업데이트 코드는 아래와 같다. 기존에 썼던 onSuccess는 더이상 사용하지 않고, onMutate / onError / onSettled 이 조합이 필요하다. 실행순서는 다음과 같다. onMutate ➡️ mutationFn ➡️ onError ➡️ onSettled 순이다.

 

1. onMutate

이때 cart 쿼리 키로 데이터를 가지고 오는 중이라면, 기존 쿼리를 일단 취소해야한다. 왜냐하면 사이드 이펙트를 일으킬 수 있기 때문. 

getQueryData로 가져온 데이터를 백업 해두고 setQueryData로 변경될 것으로 예측한 데이터를 넣어주면 된다. 

onMutate: async () => {
      await queryClient.cancelQueries({
        queryKey: ['cart']
      }); // 기존 쿼리 취소

      const previousCart = queryClient.getQueryData(['cart']); // 기존 데이터 저장

      // optimistic update
      queryClient.setQueryData(['cart'], (oldData: any) => {
        return oldData.map((item: any) =>
          item.product_id === product_id ? { ...item, count: counts + 1 } : item
        );
      });

      return { previousCart }; // 롤백을 위한 이전 데이터 반환
    },

 

2. mutationFn

실제로 서버에 요청하는 함수를 실행한다. 

  mutationFn: async () => {
      const newCount = counts + 1;
      await updateCountInDatabase(newCount);
      return newCount;
    },

 

 

3. onError

위 코드에서 리턴된 previousCart는 아래 context로 들어가게 된다. 만약 서버에 요청을 보내고 오류가 날 경우 전에 백업해둔 데이터로 원복을 해준다.

onError: (err, variables, context) => {
      queryClient.setQueryData(['cart'], context?.previousCart);
    },

 

 

4. onSettled

onMutate에서 이미 ui가 완성되었지만 한번 더 ui 갱신이 필요하거나, 혹시 모를 상황을 대비해 마지막으로 invalidateQueries로 쿼리를 무효화해준다.

 onSettled: () => {
      queryClient.invalidateQueries({
        queryKey: ['cart']
      }); // 쿼리 무효화
    }

 

 

완성 코드

코드를 변경하기 전 심화 강의자료와 스탠다드반 리액트 쿼리 강의 자료를 참고해서 구현했다. 꼼꼼한 자료 덕분인지 큰 오류 없이 잘 구현할 수 있었던 것 같다.

const addCountMutation = useMutation({
    mutationFn: async () => {
      const newCount = counts + 1;
      await updateCountInDatabase(newCount);
      return newCount;
    },
    onMutate: async () => {
      await queryClient.cancelQueries({
        queryKey: ['cart']
      }); // 기존 쿼리 취소

      const previousCart = queryClient.getQueryData(['cart']); // 기존 데이터 저장

      // optimistic update
      queryClient.setQueryData(['cart'], (oldData: any) => {
        return oldData.map((item: any) =>
          item.product_id === product_id ? { ...item, count: counts + 1 } : item
        );
      });

      return { previousCart }; // 롤백을 위한 이전 데이터 반환
    },
    onError: (err, variables, context) => {
      queryClient.setQueryData(['cart'], context?.previousCart);
    },
    onSettled: () => {
      queryClient.invalidateQueries({
        queryKey: ['cart']
      }); // 쿼리 무효화
    }
  });

 

 

프로젝트 적용

테스트하느라 37번이나 눌러봤지만.. 잘 업데이트되는 것을 확인할 수 있다.

 

+ Recent posts